diff --git a/.circleci/docker-tags.sh b/.circleci/bin/docker-tags similarity index 100% rename from .circleci/docker-tags.sh rename to .circleci/bin/docker-tags diff --git a/.circleci/build.sh b/.circleci/build.sh deleted file mode 100755 index d9bec250fdb..00000000000 --- a/.circleci/build.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -# Build and tag docker image with SHA1, branch name, git tag, and latest if necessary -set -e - -# Setup variables -DOCKER_NAMESPACE=${DOCKER_NAMESPACE:-"reactioncommerce/reaction"} -SHA1=$(git rev-parse --verify "${CIRCLE_SHA1}") -__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Build and tag docker image -# Ensure that we build the docker image and tag with the git SHA1 ref. -echo "Building the Docker image." -docker build \ - --build-arg TOOL_NODE_FLAGS="--max-old-space-size=4096" \ - --build-arg INSTALL_MONGO=true \ - -t "${DOCKER_NAMESPACE}:${SHA1}" . - -# Get tags and apply them to our Docker image -"${__dir}/docker-tags.sh" "${SHA1}" "${CIRCLE_BRANCH}" | sed 's/\//-/g' | xargs -t -I % \ - docker tag "${DOCKER_NAMESPACE}:${SHA1}" "${DOCKER_NAMESPACE}:%" - -# run the container and wait for it to boot -docker-compose -f .circleci/docker-compose.yml up -d -sleep 30 - -# use curl to ensure the app returns 200's -docker exec reaction bash -c "apt-get update && apt-get install -y curl && \ - curl --retry 10 --retry-delay 10 -v http://localhost:3000" diff --git a/.circleci/config.yml b/.circleci/config.yml index e01bc6a4569..694c5b1db3b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,75 +1,215 @@ version: 2 +# The following stanza defines a map named defaults with a variable that may be +# inserted using the YAML merge (<<: *) key later in the file to save some +# typing. See http://yaml.org/type/merge.html for details. +defaults: &defaults + environment: + - DOCKER_REPOSITORY: "reactioncommerce/reaction" + - DOCKER_NAMESPACE: "reactioncommerce" + - DOCKER_NAME: "reaction" + - TOOL_NODE_FLAGS: "--max-old-space-size=4096" + working_directory: ~/reaction-app + docker: + - image: circleci/node:8-stretch + jobs: build: - working_directory: /home/reaction - - docker: - - image: node:8 - - environment: - - DOCKER_VERSION: 17.05.0-ce - - DOCKER_COMPOSE_VERSION: 1.15.0 - - METEOR_ALLOW_SUPERUSER: true - - TOOL_NODE_FLAGS: "--max-old-space-size=4096" - + <<: *defaults steps: - - setup_remote_docker - checkout - - # install OS dependencies + - setup_remote_docker - restore_cache: name: Restoring Meteor cache key: meteor - - - run: .circleci/install.sh - + - run: + name: Install Meteor + command: | + if [[ -f ~/.meteor/meteor ]]; then \ + printf "\nMeteor already installed. Creating symlink.\n" + sudo ln -s ~/.meteor/meteor /usr/local/bin/meteor; + else + printf "\Installing Meteor\n" + curl https://install.meteor.com | /bin/sh + fi - save_cache: name: Saving Meteor to cache key: meteor paths: - ~/.meteor - - # install app dependencies - - - run: meteor npm install - - # run tests - - restore_cache: - name: Restoring Meteor dev_bundle cache - key: dev_bundle - - # run reaction tests - - run: .circleci/tests.sh - + - run: + name: Meteor NPM Install + command: meteor npm install + # Store node_modules dependency cache. + # Saved with package.json checksum and timestamped branch name keys. + - save_cache: + key: v1-node-modules-{{ checksum "package.json" }} + paths: + - node_modules - save_cache: - name: Saving Meteor dev_bundle to cache - key: dev_bundle + key: v1-node-modules-{{ .Branch }}-{{ epoch }} paths: - - /home/reaction/.meteor/local + - node_modules + docker-build: + <<: *defaults + steps: + - checkout + - setup_remote_docker + - attach_workspace: + at: docker-cache - run: - command: .circleci/build.sh + name: Discover Docker Tags + command: | + mkdir -p docker-cache + .circleci/bin/docker-tags "$CIRCLE_SHA1" "$CIRCLE_BRANCH" | sed 's/\//-/g' \ + > docker-cache/docker-tags.txt + - run: + name: Docker build + command: | + docker build \ + --build-arg TOOL_NODE_FLAGS="--max-old-space-size=4096" \ + -t "$DOCKER_REPOSITORY:$CIRCLE_SHA1" . + mkdir -p docker-cache + docker save \ + -o docker-cache/docker-image.tar \ + "$DOCKER_REPOSITORY:$CIRCLE_SHA1" no_output_timeout: 30m + - persist_to_workspace: + root: docker-cache + paths: + - docker-image.tar + - docker-tags.txt + docker-push: + <<: *defaults + steps: + - setup_remote_docker + - attach_workspace: + at: docker-cache + - run: + name: Load Docker Image + command: | + docker load < docker-cache/docker-image.tar - run: - command: .reaction/jsdoc/build.sh - no_output_timeout: 2m + name: Tag Docker Image + command: | + cat docker-cache/docker-tags.txt \ + | xargs -t -I % \ + docker tag \ + "$DOCKER_REPOSITORY:$CIRCLE_SHA1" \ + "$DOCKER_REPOSITORY:%" + - run: + name: Docker Push + command: | + if [ -z "$CIRCLE_PR_USERNAME" ]; then \ + docker login -u "$DOCKER_USER" -p "$DOCKER_PASS" + docker push "$DOCKER_REPOSITORY:$CIRCLE_SHA1" + cat docker-cache/docker-tags.txt \ + | xargs -t -I % \ + docker push "$DOCKER_REPOSITORY:%" + else + echo "No deploy for forks" + fi - # deploy the docs if on master branch - - deploy: - name: Deploy to S3 if tests pass and branch is Master + deploy-docs: + <<: *defaults + steps: + - checkout + - run: + name: Install AWS CLI + command: sudo apt-get -y -qq install awscli + - run: + name: NPM Install JSDoc + command: sudo npm install -g jsdoc + - run: + name: Build JSDoc files command: | - if [ "${CIRCLE_BRANCH}" == "master" ]; then - if [[ "${API_DOC_BUCKET}" && "${API_DOC_BUCKET_REGION}" ]]; then - aws s3 sync /tmp/reaction-docs ${API_DOC_BUCKET} --delete --region ${API_DOC_BUCKET_REGION} - else - echo "S3 bucket configuration not found for jsdocs" - echo "Set API_DOC_BUCKET and API_DOC_BUCKET_REGION to build and deploy jsdocs to S3" - fi + jsdoc . \ + --verbose \ + --configure .reaction/jsdoc/jsdoc.json \ + --readme .reaction/jsdoc/templates/static/README.md + - run: + name: Deploy Doc files to S3 + command: | + if [[ "${API_DOC_BUCKET}" && "${API_DOC_BUCKET_REGION}" ]]; then + aws s3 sync /tmp/reaction-docs ${API_DOC_BUCKET} --delete --region ${API_DOC_BUCKET_REGION} else - echo "Not master branch so not deploying" + echo "S3 bucket configuration not found for jsdocs" + echo "Set API_DOC_BUCKET and API_DOC_BUCKET_REGION to build and deploy jsdocs to S3" + exit 1; fi - - deploy: - name: Docker Image Deploment - command: .circleci/deploy.sh + + eslint: + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - v1-node-modules-{{ checksum "package.json" }} + - v1-node-modules-{{ .Branch }} + - v1-node-modules-master + - run: + name: Run Lint + command: | + npm run lint + + test: + <<: *defaults + steps: + - checkout + - restore_cache: + name: Restoring Meteor cache + key: meteor + - run: + name: Link Restored Meteor + command: sudo ln -s ~/.meteor/meteor /usr/local/bin/meteor + - restore_cache: + # Fall back to less specific caches + keys: + - v1-node-modules-{{ checksum "package.json" }} + - v1-node-modules-{{ .Branch }} + - v1-node-modules-master + - run: + name: Install Reaction CLI + command: sudo npm install -g reaction-cli + - run: + name: Reaction Test + command: .circleci/tests.sh + + dockerfile-lint: + <<: *defaults + docker: + - image: hadolint/hadolint + steps: + - checkout + - setup_remote_docker + - run: + name: Dockerfile Lint + command: | + hadolint Dockerfile +workflows: + version: 2 + build_and_test: + jobs: + - build + - dockerfile-lint + - test: + requires: + - build + - eslint: + requires: + - build + - docker-build: + context: reaction-build-read + - docker-push: + context: reaction-publish-docker + requires: + - docker-build + - deploy-docs: + requires: + - test + - docker-build + filters: + branches: + only: /^master$/ diff --git a/.circleci/deploy.sh b/.circleci/deploy.sh deleted file mode 100755 index d610a16b406..00000000000 --- a/.circleci/deploy.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -## Required environment variables in your CircleCI dashboard -# (used to push to Docker Hub) -# -# $DOCKER_USER - Docker Hub username -# $DOCKER_PASS - Docker Hub password - -## Optional Environment Variables -# (used to customize the destination on Docker Hub without having to edit the CircleCI config) -# -# $DOCKER_NAMESPACE - the image name for production deployments [Default]: reactioncommerce/reaction - -set -e - -# Setup variables -DOCKER_NAMESPACE=${DOCKER_NAMESPACE:-"reactioncommerce/reaction"} -SHA1=$(git rev-parse --verify "${CIRCLE_SHA1}") -__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Login to docker -docker login -u "${DOCKER_USER}" -p "${DOCKER_PASS}" - -# Push image and SHA1 tag -echo "Pushing docker image with SHA1 tag ${DOCKER_NAMESPACE}:${SHA1}" -docker push "${DOCKER_NAMESPACE}:${SHA1}" - -# Push remaining tags (git tags, branch, "latest" if applicable) -echo "Pushing remaining tags" -"${__dir}/docker-tags.sh" "${SHA1}" "${CIRCLE_BRANCH}" | sed 's/\//-/g' | xargs -t -I % \ - docker push "${DOCKER_NAMESPACE}:%" diff --git a/.circleci/docker-compose.yml b/.circleci/docker-compose.yml index 6613263a63e..f5a61cb78dd 100644 --- a/.circleci/docker-compose.yml +++ b/.circleci/docker-compose.yml @@ -13,5 +13,5 @@ reaction: MONGO_URL: "mongodb://mongo:27017/reaction" mongo: - image: mongo:latest - command: mongod --storageEngine=wiredTiger --bind_ip_all + image: mongo:3.4 + command: mongod --storageEngine=wiredTiger diff --git a/.circleci/install.sh b/.circleci/install.sh deleted file mode 100755 index 13f1cdc8162..00000000000 --- a/.circleci/install.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -set -x - -# install OS dependencies -apt-get update -apt-get install -y locales - - -# fix Meteor/Mongo locale issue on Debian -# https://github.com/meteor/meteor/issues/4019 -locale-gen en_US.UTF-8 -localedef -i en_GB -f UTF-8 en_US.UTF-8 - - -# install Docker client -curl -L -o /tmp/docker-$DOCKER_VERSION.tgz https://get.docker.com/builds/Linux/x86_64/docker-$DOCKER_VERSION.tgz -tar -xz -C /tmp -f /tmp/docker-$DOCKER_VERSION.tgz -mv /tmp/docker/* /usr/bin -docker -v - - -# install Docker Compose -curl -L https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose -chmod +x /usr/local/bin/docker-compose - - -# install Meteor if it's not already -if [[ -f ~/.meteor/meteor ]]; then - printf "\nMeteor already installed. Creating symlink.\n" - ln -s ~/.meteor/meteor /usr/local/bin/meteor; -else - printf "\Installing Meteor\n" - curl https://install.meteor.com | /bin/sh -fi - - -# install Reaction CLI -yarn global add reaction-cli diff --git a/.circleci/tests.sh b/.circleci/tests.sh index 07992b2f359..0921184478b 100755 --- a/.circleci/tests.sh +++ b/.circleci/tests.sh @@ -6,8 +6,6 @@ # often avoided and the false positive doesn't fail the CircleCI build. # # This will update npm deps which takes a while -# cfs:tempstore: updating npm dependencies -- combined-stream... -# cfs:gridfs: updating npm dependencies -- mongodb, gridfs-stream... set +e diff --git a/.eslintignore b/.eslintignore index 3e4d8db436b..407717de973 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,7 +1,8 @@ # things to ignore in lint *.min.* -server/plugins.js +.reaction/jsdoc/templates/* client/plugins.js +imports/plugins/custom/* packages/* -.reaction/jsdoc/templates/* +server/plugins.js diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 00000000000..cfa1b01697b --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,3 @@ +ignored: + - DL3008 #Pin versions in apt get install. We'll opt for updates. + - DL3016 #Pin versions in npm install rule currently affects "meteor npm install". Versions are in package.json diff --git a/.meteor/packages b/.meteor/packages index 2e857eac51a..2614e6f3d57 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -14,7 +14,7 @@ es5-shim@4.7.0 # ECMAScript 5 compatibility for older browsers. ecmascript@0.10.0 # Enable ECMAScript2015+ syntax in app code audit-argument-checks@1.0.7 # ensure meteor method argument validation browser-policy@1.1.0 # security-related policies enforced by newer browsers -juliancwirko:postcss # CSS post-processing plugin (replaces standard-minifier-css) +juliancwirko:postcss@1.3.0 # CSS post-processing plugin (replaces standard-minifier-css) session@1.1.7 # ReactiveDict whose contents are preserved across Hot Code Push tracker@1.1.3 # Meteor transparent reactive programming library mongo@1.4.2 @@ -48,23 +48,16 @@ twitter-config-ui@1.0.0 # Community Packages alanning:roles -aldeed:autoform -aldeed:collection2 -aldeed:schema-index +aldeed:autoform@6.2.0 +aldeed:collection2@3.0.0 +aldeed:schema-index@3.0.0 aldeed:template-extension bozhao:accounts-instagram -cfs:filesystem -cfs:graphicsmagick -cfs:gridfs -cfs:standard-packages -cfs:storage-adapter -cfs:ui dispatch:run-as-user matb33:collection-hooks meteorhacks:ssr meteorhacks:subs-manager ongoworks:security -raix:ui-dropped-event tmeasday:publish-counts percolate:migrations gadicc:blaze-react-component diff --git a/.meteor/versions b/.meteor/versions index 4728e01f09d..95a92a0cc10 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -5,13 +5,9 @@ accounts-oauth@1.1.15 accounts-password@1.5.0 accounts-twitter@1.4.1 alanning:roles@1.2.16 -aldeed:autoform@5.8.1 -aldeed:browser-tests@0.1.1 -aldeed:collection2@2.10.0 -aldeed:collection2-core@1.2.0 -aldeed:schema-deny@1.1.0 -aldeed:schema-index@1.1.1 -aldeed:simple-schema@1.5.3 +aldeed:autoform@6.2.0 +aldeed:collection2@3.0.0 +aldeed:schema-index@3.0.0 aldeed:template-extension@4.1.0 allow-deny@1.1.0 audit-argument-checks@1.0.7 @@ -32,26 +28,6 @@ browser-policy-framing@1.1.0 caching-compiler@1.1.11 caching-html-compiler@1.1.2 callback-hook@1.1.0 -cfs:access-point@0.1.49 -cfs:base-package@0.0.30 -cfs:collection@0.5.5 -cfs:collection-filters@0.2.4 -cfs:data-man@0.0.6 -cfs:file@0.1.17 -cfs:filesystem@0.1.2 -cfs:graphicsmagick@0.0.18 -cfs:gridfs@0.0.34 -cfs:http-methods@0.0.32 -cfs:http-publish@0.0.13 -cfs:power-queue@0.9.11 -cfs:reactive-list@0.0.9 -cfs:reactive-property@0.0.4 -cfs:standard-packages@0.5.10 -cfs:storage-adapter@0.2.4 -cfs:tempstore@0.1.6 -cfs:ui@0.1.3 -cfs:upload-http@0.0.20 -cfs:worker@0.1.5 check@1.3.0 coffeescript@1.0.17 dburles:factory@1.1.0 @@ -62,7 +38,6 @@ ddp-rate-limiter@1.0.7 ddp-server@2.1.2 deps@1.0.12 diff-sequence@1.1.0 -dispatch:mocha@0.4.1 dispatch:run-as-user@1.1.1 dynamic-import@0.3.0 ecmascript@0.10.4 @@ -85,7 +60,7 @@ http@1.4.0 id-map@1.1.0 johanbrook:publication-collector@1.1.0 jquery@1.11.11 -juliancwirko:postcss@1.2.0 +juliancwirko:postcss@1.3.0 launch-screen@1.1.1 less@2.7.12 livedata@1.0.18 @@ -93,7 +68,6 @@ localstorage@1.2.0 logging@1.1.19 matb33:collection-hooks@0.8.4 mdg:validated-method@1.1.0 -mdg:validation-error@0.5.1 meteor@1.8.2 meteor-base@1.3.0 meteorhacks:ssr@2.2.0 @@ -111,7 +85,6 @@ momentjs:moment@2.19.4 mongo@1.4.3 mongo-dev-server@1.1.0 mongo-id@1.0.6 -mongo-livedata@1.0.12 npm-bcrypt@0.9.3 npm-mongo@2.2.34 oauth@1.2.1 @@ -127,7 +100,6 @@ practicalmeteor:mocha-core@1.0.1 practicalmeteor:sinon@1.14.1_2 promise@0.10.2 raix:eventemitter@0.1.3 -raix:ui-dropped-event@0.0.7 random@1.1.0 rate-limit@1.0.9 reactive-dict@1.2.0 @@ -150,6 +122,7 @@ templating@1.3.2 templating-compiler@1.3.3 templating-runtime@1.3.2 templating-tools@1.1.2 +tmeasday:check-npm-versions@0.3.2 tmeasday:publish-counts@0.8.0 tracker@1.1.3 twitter-config-ui@1.0.0 diff --git a/.reaction/jsdoc/build.sh b/.reaction/jsdoc/build.sh deleted file mode 100755 index 2cc4327e618..00000000000 --- a/.reaction/jsdoc/build.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -## runs jsdoc -## master branch docs will be published via aws s3 sync when all test pass -set -e - -## install awscli -apt-get -y -qq install awscli -## install jsdoc -npm install -g jsdoc -# build new jsdocs -echo "Running jsdocs on the reaction codebase" -jsdoc . --verbose --configure .reaction/jsdoc/jsdoc.json --readme .reaction/jsdoc/templates/static/README.md diff --git a/.snyk b/.snyk index 9befd53e858..5a628cbea9d 100644 --- a/.snyk +++ b/.snyk @@ -1,68 +1,128 @@ # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. -version: v1.10.1 +version: v1.10.2 # ignores vulnerabilities until expiry date; change duration by modifying expiry date ignore: 'npm:hoek:20180212': - nexmo > jsonwebtoken > joi > hoek: reason: No patch available expires: '2018-03-22T04:22:44.834Z' + npm > request > hawk > hoek: + reason: no patch available + expires: '2018-04-05T17:36:16.541Z' - nexmo > jsonwebtoken > joi > topo > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > request > hawk > boom > hoek: + reason: no patch available + expires: '2018-04-05T17:36:16.541Z' - prerender-node > request > hawk > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > request > hawk > sntp > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - prerender-node > request > hawk > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > request > hawk > cryptiles > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - prerender-node > request > hawk > sntp > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-registry-client > request > hawk > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - prerender-node > request > hawk > cryptiles > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-registry-client > request > hawk > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-registry-client > request > hawk > sntp > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > boom > hoek: reason: no patch availalbe expires: '2018-03-22T04:22:44.834Z' + npm > npm-registry-client > request > hawk > cryptiles > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > sntp > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-lifecycle > node-gyp > request > hawk > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > cryptiles > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-lifecycle > node-gyp > request > hawk > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-lifecycle > node-gyp > request > hawk > sntp > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > npm-lifecycle > node-gyp > request > hawk > cryptiles > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > sntp > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > libcipm > npm-lifecycle > node-gyp > request > hawk > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > cryptiles > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > libcipm > npm-lifecycle > node-gyp > request > hawk > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runner > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > libcipm > npm-lifecycle > node-gyp > request > hawk > sntp > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runner > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + npm > libcipm > npm-lifecycle > node-gyp > request > hawk > cryptiles > boom > hoek: + reason: no patch + expires: '2018-04-05T17:36:16.541Z' - jest > jest-cli > jest-runner > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > sntp > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > gcp-metadata > retry-request > request > hawk > hoek': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - jest > jest-cli > jest-runner > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > cryptiles > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > gcp-metadata > retry-request > request > hawk > boom > hoek': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - jest > jest-cli > jest-runner > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > gcp-metadata > retry-request > request > hawk > sntp > hoek': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - jest > jest-cli > jest-runner > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > boom > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' + '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > gcp-metadata > retry-request > request > hawk > cryptiles > boom > hoek': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - jest > jest-cli > jest-runner > jest-runtime > jest-haste-map > sane > fsevents > node-pre-gyp > request > hawk > sntp > hoek: reason: no patch available expires: '2018-03-22T04:22:44.834Z' @@ -93,15 +153,27 @@ ignore: - eslint-config-react-tools > babel-eslint > babel-types > lodash: reason: no patch available expires: '2018-03-22T04:49:05.875Z' + authorize-net > 42-cent-base > lodash: + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - eslint-config-react-tools > eslint-plugin-class-property > eslint > table > lodash: reason: no patch available expires: '2018-03-22T04:49:05.875Z' + twilio > lodash: + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - eslint-config-react-tools > eslint-plugin-class-property > eslint > lodash: reason: no patch available expires: '2018-03-22T04:49:05.875Z' + velocity-react > lodash: + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - eslint-config-react-tools > eslint > table > lodash: reason: no patch available expires: '2018-03-22T04:49:05.875Z' + npm > cli-table2 > lodash: + reason: no patch + expires: '2018-04-05T17:36:16.542Z' - eslint-config-react-tools > eslint > inquirer > lodash: reason: no patch available expires: '2018-03-22T04:49:05.875Z' @@ -324,4 +396,22 @@ ignore: - '*': reason: no patch available and dev dependency expires: 2018-03-22T08:23:02.718Z + 'npm:node-forge:20180226': + - '@reactioncommerce/file-collections > tus-node-server > google-auto-auth > google-auth-library > gtoken > google-p12-pem > node-forge': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' + - '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > @google-cloud/common > google-auto-auth > google-auth-library > gtoken > google-p12-pem > node-forge': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' + - '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > google-auth-library > gtoken > google-p12-pem > node-forge': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' + 'npm:ssri:20180214': + - npm > npm-registry-client > ssri: + reason: no patch + expires: '2018-04-05T17:36:16.542Z' + 'npm:tunnel-agent:20170305': + - '@reactioncommerce/file-collections > tus-node-server > @google-cloud/storage > gcs-resumable-upload > google-auto-auth > gcp-metadata > retry-request > request > tunnel-agent': + reason: no patch + expires: '2018-04-05T17:36:16.542Z' patch: {} diff --git a/CHANGELOG.md b/CHANGELOG.md index f69202bd17f..34b864200ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,161 @@ +# v1.9.0 +This release contains a lot of fixes, some of them performance related and several enormous refactors. +The three biggest changes are: +1. We've migrated from the Meteor version of Simple Schema to the npm version. See notes in the breaking changes section below. +2. We've dropped our dependency on the deprecated Meteor-CollectionFS package. We've replaced it with an npm package we've created called [reaction-file-collections](https://github.com/reactioncommerce/reaction-file-collections) +3. We've created a new catalog collection for use on the Product Grid when viewed by a consumer or other user without a product admin role + +There's a full list of changes and fixes below, as well as detailed explanations of potential breaking changes and what you might need to do to migrate + +## BREAKING CHANGES +This is a breaking change for any plugin that implements or modifies a schema based on the Meteor simple-schema package. + +### From the Simple Schema update +This PR updates the `aldeed:simple-schema` Meteor package dependency to instead depend on the `simpl-schema` NPM package, which is the newest release of the same library. As part of this change, there are several breaking changes and other gotchas to be aware of. + +IMPORTANT! The NPM package does not play nice with the previous Meteor package. After updating to this Reaction release, run the app one time, and then look at the .meteor/versions file. Make sure that aldeed:simple-schema is not listed. If it is there, that is because you depend on another Meteor package that depends on aldeed:simple-schema. You will have to update or remove any such packages (with meteor remove / meteor add) until aldeed:simple-schema disappears from your .meteor/versions file. +Search your app for any import { SimpleSchema } from "meteor/aldeed:simple-schema" lines that you have added in your custom code, and replace them with import SimpleSchema from "simpl-schema" +Be aware that the package name does not have the "e" on "simpl". (There is a different NPM package called simple-schema with the "e", and that is NOT the one you want.) +If you have your own custom schemas, refer to the SimpleSchema changelog to update them for the breaking changes: https://github.com/aldeed/meteor-simple-schema/blob/master/CHANGELOG.md#200 +If you use attachSchema in your code, be aware that passing an array to attachSchema will no longer work. You should first merge all the schemas and then pass the combined schema to attachSchema + +Please read the PR if you need more details [Use NPM SimpleSchema rather than Meteor #3331](https://github.com/reactioncommerce/reaction/pull/3331) + +### From the removal of CollecitonFS +#### If you've saved the file URLs anywhere, they're now different. +``` +/assets/files/:collectionName/:fileId/:filename +``` +becomes +``` +/assets/files/:collectionName/:fileId/:primaryStoreName/:filename +``` + +and +``` +/assets/files/:collectionName/:fileId/:filename?store=storeName +``` + +becomes + +``` +/assets/files/:collectionName/:fileId/:storeName/:filename +``` + +#### We've deleted some unused Blaze templates rather than update URL handling within them: +- shopBrandImageOption +- ordersListItems +- select +- upload +- productMetaField +- productMetaFieldForm +- metaComponent +- productDetailEdit +- productDetailField +- productImageGallery +- imageDetail +- imageUploader +- productSocial +- variantList +- variant +- Media-related publishing is changed and improved: + +#### Publications have been added, removed, or changed: +- `CartItemImage` publication is removed +- `CartImages` now takes an ID +- Added `ProductGridMedia` to replace Media being included with the products publication for the grid +- Added `ProductMedia` +- Added `OrderImages`, similar to `CartImages`, used for order now rather than reusing CartImages + +Full notes on the PR to replace CFS #3782 + +### From the customer product catalog +The old `imports/plugins/included/product-variants/containers/productsContainer.js` has been renamed to `productsContainerAdmin.js` and a new component named `productsContainer.js` now handles which products container to load based on the user's permissions. Full notes on the PR #3876 + +### From the Dockerfile updates +reactioncommerce/base:v4.0.1 removed the following: +- Removed the conditional MongoDB installation (via $INSTALL_MONGO env). Use `mongo` as a service in docker-compose, see example in README. +- Removed the conditional PhantomJS installation (via $INSTALL_PHANTOMJS env). If PhantomJS is required in your build, you can include it in your custom Dockerfile. +Full notes on the PR + +## Dockerfile Updates +- Base image updated to `reactioncommerce/base:v4.0.1` which has: + - `node:8.9.4` as base image (same Debian base as before, but with Node 8 preinstalled) + - Meteor 1.6.1 preinstalled +- [Multi-stage build support](https://docs.docker.com/develop/develop-images/multistage-build/). + This helped reduce the size of the production image by removing un-required dependencies. +- Final production bundle uses `node:8.9.4-slim` + +## Docker Compose changes +- Updated existing `docker-compose.yml` to serve as the config for running a local development environment. +- Added a new `docker-compose-demo.yml` for testing out production builds (this is the replacement for the previous `docker-compose.yml`). +## Upgrades +- Use NPM SimpleSchema rather than Meteor (#3331) + +## CI +We've updated our circle ci config to use [v2 of Workflows](https://circleci.com/docs/2.0/workflows/). This permits us to run additional automated tests on circle instead of using other services. We now have 6 workflow steps that must pass before a PR can be merged. + +## Refactor +- refactor: rename Import to Importer (#3613) .. Resolves ##1364 +- refactor: convert search modal wrapper to React (#3853) +- refactor: replace CFS (#3782) +- refactor: customer product grid publishing (#3876) .. Resolves #3662 +- refactor: remove unused collection hook (#3950) + +## Fix + - fix: inventory updated on shopify sync (#3897) .. Resolves #3718 + - fix: settings startup error (#3939) + - fix: email validation (#3899) .. Resolves #3733 + - fix: change all email verification links to use tokens (#3884) + - fix: update shopId the right way. (#3947) .. Resolves #3945 + - fix: migration version after SimpleSchema NPM merge (#3929) + - fix: ui glitches using dynamic merchandising (#3932) + - fix: setting or changing a products perma-link causes hard refresh (#3755) .. #2246 + - fix: removing search-mongo plugin causes errors at startup (#3837) .. Resolves #3797 + - fix: `Reaction.getShopId` missing `()` (#3891) + - fix: added delay and loader (#3796) .. #2863 + - fix: add back missing browser policy (#3894) + - fix: discount codes limits are not honored (#3824) .. #3783 + - fix: remove cfs:graphicsmagick (#3869) .. Resolves #3868 + - fix: password validation (#3860) .. Resolves #3854 + - fix: set localstorage even when no Meteor.user exists (#3856) .. Resolves #3846 + - fix: handle misconfigured Avalara api (#3827) .. Resolves #3813 + - fix: fix for "capturing bulk orders throws server side error" (#3822) .. Resolves #3705 + - fix: shop switcher opens off-screen (#3809) .. Resolves #3619 + - fix: /shop added to URL (#3794) .. Resolves #2810 + - fix: adding country code to phone number before sending SMS (#3751) .. Resolves #3597 + - fix: changing the permalink before publishing a product results in "not found" (#3748) + - fix: errors when updating default shipping and billing addresses (#3802) + - fix: delayed response in localization settings (#3872) + - fix: handle integer schema type when getting form field type (#3930) + - fix: check for number if sms is enabled. (#3983) .. Resolves #3965 + - fix: marketplace shipping (#3981) .. Resolves #3979 + - fix: summary not shown in Invoice (#3989) + - fix: dirty badge in product grid does not work (#3984) + - fix: reactivity error when products are not published yet (#3970) + - fix: global route hooks (#3896) .. Resolves #3895 + - fix: added all the missing avalara settings fields to the fieldsProps… (#3969) + - fix: publishing group related to current shop (#3943) .. Resolves #3942 + - fix: break payment before sending to paypal (#3859) .. Resolves #1236 + - fix: delete shipping rates one at a time (#3968) + - fix: card validator (#3892) .. Resolves #3875 + - fix: can't input refund properly (#3893) .. Resolves #3703 + - fix: clean paymentMethod objects before validating (#3961) + - fix: console error during checkout (#3948) + + ## Chore + - chore: add imports/plugins/custom to eslint ignore (#3901) + - chore: update Docker base for multi-stage builds (#3653) + - chore: use circleci workflows 2 in circle config (#3959) + - chore: remove ability to load Meteor.settings from settings.json (#3951) + - chore: upgrade react-dates to 16.3.6 (#3952) + +## Docs + - docs(jsdoc) - document and namespace Router.Hooks methods (#3874) .. Resolves #3840 + +## Contributors +Thanks to @pmn4 for contributing to this release! + # v1.8.2 ## Fixes - fix: added unique to slug (#3745) .. Resolves #2736 diff --git a/Dockerfile b/Dockerfile index d40b8858053..3392b776d8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,48 @@ -FROM reactioncommerce/base:v3.0.0 +############################################################################## +# meteor-dev stage - builds image for dev and used with docker-compose.yml +############################################################################## +FROM reactioncommerce/base:v4.0.1 as meteor-dev + +LABEL maintainer="Reaction Commerce " + +ENV PATH $PATH:/home/node/.meteor + +COPY --chown=node package.json $APP_SOURCE_DIR/ + +RUN meteor npm install + +COPY --chown=node . $APP_SOURCE_DIR + + +############################################################################## +# builder stage - builds the production bundle +############################################################################## +FROM meteor-dev as builder + +RUN printf "\\n[-] Running Reaction plugin loader...\\n" \ + && reaction plugins load +RUN printf "\\n[-] Building Meteor application...\\n" \ + && meteor build --server-only --architecture os.linux.x86_64 --directory "$APP_BUNDLE_DIR" + +WORKDIR $APP_BUNDLE_DIR/bundle/programs/server/ + +RUN meteor npm install --production + + +############################################################################## +# final build stage - create the final production image +############################################################################## +FROM node:8.9.4-slim # Default environment variables ENV ROOT_URL "http://localhost" -ENV MONGO_URL "mongodb://127.0.0.1:27017/reaction" +ENV PORT 3000 + +# grab the dependencies and built app from the previous builder image +COPY --chown=node --from=builder /opt/reaction/dist/bundle /app + +WORKDIR /app + +EXPOSE 3000 + +CMD ["node", "main.js"] diff --git a/README.md b/README.md index e19645af4d5..d47ffb19e8c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ cd reaction reaction ``` +You can also run the app locally using [`docker-compose`](https://docs.docker.com/compose/) by running: + +```sh +docker-compose up +``` + +This will use the `docker-compose.yml` file. This can be used to evaluate the app locally (on all Operating Systems supported by Docker), +however, for active local development or customization, it is better to run `reaction` outside of Docker for faster app builds. + Learn more on how to [configure your project](https://docs.reactioncommerce.com/reaction-docs/master/configuration). # Get involved @@ -95,6 +104,16 @@ Get more details in our [Contributing Guide](https://docs.reactioncommerce.com/r We ensure that all releases are deployable as [Docker](https://hub.docker.com/r/reactioncommerce/reaction/) containers. While we don't regularly test other methods of deployment, our community has documented deployment strategies for AWS, [Digital Ocean](https://gist.github.com/jshimko/745ca66748846551692e24c267a56060), and Galaxy. For an introduction to Docker deployment, the [Reaction deployment guide](https://docs.reactioncommerce.com/reaction-docs/master/deploying) has detailed examples. +We've included a demo [docker-compose file](https://github.com/reactioncommerce/reaction/blob/master/docker-compose-demo.yml) in the repository. +It shows how to use `mongo` as a service with your Reaction app. It can be used to do a demo of your production build by running this command: + +```sh +docker-compose -f docker-compose-demo.yml up +``` + +You can also use this file as starting point for your production docker-compose setup. + + ### License Copyright © [GNU General Public License v3.0](./LICENSE.md) diff --git a/client/modules/core/helpers/apps.js b/client/modules/core/helpers/apps.js index c0bb4a328ca..b637070c498 100644 --- a/client/modules/core/helpers/apps.js +++ b/client/modules/core/helpers/apps.js @@ -4,8 +4,6 @@ import { Meteor } from "meteor/meteor"; import { Roles } from "meteor/alanning:roles"; import { Reaction } from "/client/api"; import { Packages, Shops } from "/lib/collections"; -import { Registry } from "/lib/collections/schemas/registry"; - /** * @@ -149,23 +147,17 @@ export function Apps(optionHash) { return false; } - const filterKeys = Object.keys(itemFilter); // Loop through all keys in the itemFilter // each filter item should match exactly with the property in the registry or // should be included in the array if that property is an array - return filterKeys.every((property) => { - // Check to see if the schema for this property is an array - // if so, we want to make sure that this item is included in the array - if (Array.isArray(Registry._schema[property].type())) { - // Check to see if the registry entry is an array. - // Legacy registry entries could exist that use a string even when the schema requires an array. - if (Array.isArray(item[property])) { - return item[property].includes(itemFilter[property]); - } - } + return Object.keys(itemFilter).every((property) => { + const filterVal = itemFilter[property]; + const itemVal = item[property]; + // Check to see if the registry entry is an array. + // Legacy registry entries could exist that use a string even when the schema requires an array. // If it's not an array, the filter should match exactly - return item[property] === itemFilter[property]; + return Array.isArray(itemVal) ? itemVal.includes(filterVal) : itemVal === filterVal; }); }); @@ -175,9 +167,7 @@ export function Apps(optionHash) { }); // Sort apps by priority (registry.priority) - const sortedApps = reactionApps.sort((a, b) => a.priority - b.priority).slice(); - - return sortedApps; + return reactionApps.sort((a, b) => a.priority - b.priority).slice(); } // Register global template helper diff --git a/client/modules/core/main.js b/client/modules/core/main.js index 58dc9613c00..8683f5cde55 100644 --- a/client/modules/core/main.js +++ b/client/modules/core/main.js @@ -18,6 +18,9 @@ import { Router } from "/client/modules/router"; // This is placed outside the main object to make it a private variable. // access using `Reaction.state` const reactionState = new ReactiveDict(); + +export const userPrefs = new ReactiveVar(undefined, (val, newVal) => JSON.stringify(val) === JSON.stringify(newVal)); + const deps = new Map(); /** * Reaction namespace @@ -382,11 +385,10 @@ export default { } }); } - const packageSettings = store.get(packageName) || {}; - packageSettings[preference] = value; - return store.set(packageName, packageSettings); } - return false; + const packageSettings = store.get(packageName) || {}; + packageSettings[preference] = value; + return store.set(packageName, packageSettings); }, updateUserPreferences(packageName, preference, values) { @@ -485,7 +487,11 @@ export default { getShopPrefix() { const shopName = this.getShopName(); if (shopName) { - return `/${this.getSlug(shopName.toLowerCase())}`; + return Router.pathFor("index", { + hash: { + shopSlug: this.getSlug(shopName.toLowerCase()) + } + }); } }, @@ -532,13 +538,9 @@ export default { }, allowGuestCheckout() { - let allowGuest = false; const settings = this.getShopSettings(); // we can disable in admin, let's check. - if (settings.public && settings.public.allowGuestCheckout) { - allowGuest = settings.public.allowGuestCheckout; - } - return allowGuest; + return !!(settings.public && settings.public.allowGuestCheckout); }, /** * canInviteToGroup - client (similar to server/api canInviteToGroup) diff --git a/client/modules/core/startup.js b/client/modules/core/startup.js index f426f6f9122..f9c6a2328bd 100644 --- a/client/modules/core/startup.js +++ b/client/modules/core/startup.js @@ -5,7 +5,7 @@ import { Accounts as AccountsCollection } from "/lib/collections"; import { Accounts } from "meteor/accounts-base"; import { Reaction, Logger } from "/client/api"; - +import { userPrefs } from "./main"; const cookieName = "_RcFallbackLoginToken"; @@ -16,8 +16,9 @@ const cookieName = "_RcFallbackLoginToken"; Meteor.startup(() => { // init the core Reaction.init(); + // initialize anonymous guest users - return Tracker.autorun(() => { + Tracker.autorun(() => { const userId = Meteor.userId(); // Load data from Accounts collection into the localStorage @@ -30,7 +31,12 @@ Meteor.startup(() => { Object.keys(user.profile.preferences).forEach((packageName) => { const packageSettings = user.profile.preferences[packageName]; Object.keys(packageSettings).forEach((preference) => { - Reaction.setUserPreferences(packageName, preference, packageSettings[preference]); + if (packageName === "reaction" && preference === "activeShopId") { + // Because activeShopId is cached on client side. + Reaction.setShopId(packageSettings[preference]); + } else { + Reaction.setUserPreferences(packageName, preference, packageSettings[preference]); + } }); }); } @@ -73,6 +79,15 @@ Meteor.startup(() => { } } }); + + // Set up an autorun to get fine-grained reactivity on only the + // user preferences + Tracker.autorun(() => { + const userId = Meteor.userId(); + if (!userId) return; + const user = Meteor.users.findOne(userId, { fields: { profile: 1 } }); + userPrefs.set((user && user.profile && user.profile.preferences) || undefined); + }); }); function isLocalStorageAvailable() { diff --git a/client/modules/i18n/main.js b/client/modules/i18n/main.js index 3a8e6157712..c5fc302ba62 100644 --- a/client/modules/i18n/main.js +++ b/client/modules/i18n/main.js @@ -1,7 +1,8 @@ import i18next from "i18next"; +import { values } from "lodash"; +import SimpleSchema from "simpl-schema"; import { Meteor } from "meteor/meteor"; import { Tracker } from "meteor/tracker"; -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { Reaction } from "/client/api"; /** @@ -39,25 +40,22 @@ export function getBrowserLanguage() { * @return {Object} return schema label object */ export function getLabelsFor(schema, name) { + const titleCaseName = name.charAt(0).toLowerCase() + name.slice(1); const labels = {}; // loop through all the rendered form fields and generate i18n keys - for (const fieldName of schema._schemaKeys) { - const i18nKey = `${name.charAt(0).toLowerCase() + name.slice(1)}.${ - fieldName - .split(".$").join("")}`; + Object.keys(schema.mergedSchema()).forEach((fieldName) => { + const i18nKey = `${titleCaseName}.${fieldName.split(".$").join("")}`; // translate autoform label const t = i18next.t(i18nKey); - if (new RegExp("string").test(t) !== true && t !== i18nKey) { - if (t) { - labels[fieldName] = t; - } + if (t && new RegExp("string").test(t) !== true && t !== i18nKey) { + labels[fieldName] = t; } - } + }); return labels; } /** - * @name getMessagesFor + * @name getValidationErrorMessages * @method * @memberof i18n * @summary Get i18n messages for autoform messages. Currently using a globalMessage namespace only. @@ -67,17 +65,15 @@ export function getLabelsFor(schema, name) { * @todo Implement messaging hierarchy from simple-schema * @return {Object} returns i18n translated message for schema labels */ -export function getMessagesFor() { +export function getValidationErrorMessages() { const messages = {}; - for (const message in SimpleSchema._globalMessages) { - if ({}.hasOwnProperty.call(SimpleSchema._globalMessages, message)) { - const i18nKey = `globalMessages.${message}`; - const t = i18next.t(i18nKey); - if (new RegExp("string").test(t) !== true && t !== i18nKey) { - messages[message] = t; - } + values(SimpleSchema.ErrorTypes).forEach((errorType) => { + const i18nKey = `globalMessages.${errorType}`; + const message = i18next.t(i18nKey); + if (new RegExp("string").test(message) !== true && message !== i18nKey) { + messages[errorType] = message; } - } + }); return messages; } diff --git a/client/modules/i18n/startup.js b/client/modules/i18n/startup.js index 6a29ca4450b..c12bee3a9d8 100644 --- a/client/modules/i18n/startup.js +++ b/client/modules/i18n/startup.js @@ -6,10 +6,11 @@ import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; import { $ } from "meteor/jquery"; import { Tracker } from "meteor/tracker"; +import { ReactiveVar } from "meteor/reactive-var"; import { Reaction } from "/client/api"; import { Shops, Translations, Packages } from "/lib/collections"; import { getSchemas } from "@reactioncommerce/reaction-collections"; -import i18next, { getLabelsFor, getMessagesFor, i18nextDep, currencyDep } from "./main"; +import i18next, { getLabelsFor, getValidationErrorMessages, i18nextDep, currencyDep } from "./main"; import { mergeDeep } from "/lib/api"; // @@ -34,127 +35,130 @@ const options = { htmlTag: document.documentElement }; +const userProfileLanguage = new ReactiveVar(null); + Meteor.startup(() => { + // We need to ensure fine-grained reactivity on only the profile.lang because + // user.profile changed frequently and causes excessive reruns + Tracker.autorun(() => { + const userId = Meteor.userId(); + const user = userId && Meteor.users.findOne(userId, { fields: { profile: 1 } }); + userProfileLanguage.set((user && user.profile && user.profile.lang) || null); + }); // use tracker autorun to detect language changes // this only runs on initial page loaded // and when user.profile.lang updates Tracker.autorun(() => { - if (Reaction.Subscriptions.PrimaryShop.ready() && - Reaction.Subscriptions.MerchantShops.ready() && - Meteor.user()) { - let shopId; - - // Choose shop to get language from - if (Reaction.marketplaceEnabled && Reaction.merchantLanguage) { - shopId = Reaction.getShopId(); - } else { - shopId = Reaction.getPrimaryShopId(); - } - - const packageNamespaces = []; - - const packages = Packages.find({ + if (!Reaction.Subscriptions.PrimaryShop.ready() || + !Reaction.Subscriptions.MerchantShops.ready()) return; + + // Depend on user.profile.language reactively + const userLanguage = userProfileLanguage.get(); + + // Choose shop to get language from + let shopId; + if (Reaction.marketplaceEnabled && Reaction.merchantLanguage) { + shopId = Reaction.getShopId(); + } else { + shopId = Reaction.getPrimaryShopId(); + } + // By specifying "fields", we limit reruns to only when that field changes + const shop = Shops.findOne({ _id: shopId }, { fields: { language: 1 }, reactive: false }); + const shopLanguage = (shop && shop.language) || null; + + // Use fallbacks to determine the final language + const language = userLanguage || shopLanguage || "en"; + + // + // subscribe to user + shop Translations + // + return Meteor.subscribe("Translations", language, () => { + // Get the list of packages for that shop + const packageNamespaces = Packages.find({ shopId }, { fields: { name: 1 } - }).fetch(); - for (const pkg of packages) { - packageNamespaces.push(pkg.name); - } - - - const shop = Shops.findOne({ - _id: shopId - }); - - let language = (shop && shop.language) || "en"; + }).map((pkg) => pkg.name); - if (Meteor.user() && Meteor.user().profile && Meteor.user().profile.lang) { - language = Meteor.user().profile.lang; - } // - // subscribe to user + shop Translations + // reduce and merge translations + // into i18next resource format // - return Meteor.subscribe("Translations", language, () => { - // fetch reaction translations - const translations = Translations.find({}).fetch(); - - // - // reduce and merge translations - // into i18next resource format - // - let resources = {}; - translations.forEach((translation) => { - const resource = {}; - resource[translation.i18n] = translation.translation; - resources = mergeDeep(resources, resource); + let resources = {}; + Translations.find({}).forEach((translation) => { + resources = mergeDeep(resources, { + [translation.i18n]: translation.translation }); + }); - // - // initialize i18next - // - i18next - .use(i18nextBrowserLanguageDetector) - .use(i18nextLocalStorageCache) - .use(i18nextSprintfPostProcessor) - .init({ - detection: options, - debug: false, - ns: packageNamespaces, // translation namespace for every package - defaultNS: "core", // reaction "core" is the default namespace - fallbackNS: packageNamespaces, - lng: language, // user session language - fallbackLng: shop ? shop.language : null, // Shop language - resources - }, () => { - // someday this should work - // see: https://github.com/aldeed/meteor-simple-schema/issues/494 - - // Loop through registered Schemas - const Schemas = getSchemas(); - for (const schema in Schemas) { - if ({}.hasOwnProperty.call(Schemas, schema)) { - const ss = Schemas[schema]; - ss.labels(getLabelsFor(ss, schema)); - ss.messages(getMessagesFor(ss, schema)); - } + // + // initialize i18next + // + i18next + .use(i18nextBrowserLanguageDetector) + .use(i18nextLocalStorageCache) + .use(i18nextSprintfPostProcessor) + .init({ + detection: options, + debug: false, + ns: packageNamespaces, // translation namespace for every package + defaultNS: "core", // reaction "core" is the default namespace + fallbackNS: packageNamespaces, + lng: language, + fallbackLng: shopLanguage, + resources + }, () => { + // Loop through registered Schemas to change labels and messages + const Schemas = getSchemas(); + for (const schemaName in Schemas) { + if ({}.hasOwnProperty.call(Schemas, schemaName)) { + const schemaInstance = Schemas[schemaName]; + schemaInstance.labels(getLabelsFor(schemaInstance, schemaName)); + schemaInstance.messageBox.messages({ + [language]: getValidationErrorMessages() + }); + schemaInstance.messageBox.setLanguage(language); } + } - i18nextDep.changed(); - - // global first time init event finds and replaces - // data-i18n attributes in html/template source. - $("[data-i18n]").localize(); + i18nextDep.changed(); - // Set language prop on html element - $("html").prop("lang", language); + // global first time init event finds and replaces + // data-i18n attributes in html/template source. + $("[data-i18n]").localize(); - // apply language direction to html - if (i18next.dir(language) === "rtl") { - return $("html").addClass("rtl"); - } - return $("html").removeClass("rtl"); - }); - }); // return - } + // apply language direction to html + if (i18next.dir(language) === "rtl") { + return $("html").addClass("rtl"); + } + return $("html").removeClass("rtl"); + }); + }); // return }); - // use tracker autorun to detect currency changes - // this only runs on initial page loaded - // and when user.profile.currency updates - // although it is also triggered when profile updates ( meaning .lang ) + // Detect user currency changes. + // These two autoruns work together to ensure currencyDep is only considered + // to be changed when it should be. + // XXX currencyDep is not used by the main app. Maybe can get rid of this + // if no add-on packages use it? + const userCurrency = new ReactiveVar(); + Tracker.autorun(() => { + // We are using the reactive var only to be sure that currencyDep.changed() + // is called only when the value is actually changed from the previous value. + const currency = userCurrency.get(); + if (currency) currencyDep.changed(); + }); Tracker.autorun(() => { const user = Meteor.user(); - if (Reaction.Subscriptions.PrimaryShop.ready() && - Reaction.Subscriptions.MerchantShops.ready() && user) { - if (user.profile && user.profile.currency) { - currencyDep.changed(); - } + Reaction.Subscriptions.MerchantShops.ready() && + user) { + userCurrency.set((user.profile && user.profile.currency) || undefined); } }); + // // init i18nextJquery // diff --git a/docker-compose-demo.yml b/docker-compose-demo.yml new file mode 100644 index 00000000000..e21302fbc7d --- /dev/null +++ b/docker-compose-demo.yml @@ -0,0 +1,19 @@ +# This is a demo docker-compose file. +# Usage: docker-compose -f docker-compose-demo.yml up [-d] + +version: '3.4' + +services: + reaction: + image: reactioncommerce/reaction:latest + depends_on: + - mongo + ports: + - "3000:3000" + environment: + ROOT_URL: "http://localhost" + MONGO_URL: "mongodb://mongo:27017/reaction" + + mongo: + image: mongo:3.4 + command: mongod --storageEngine=wiredTiger diff --git a/docker-compose.yml b/docker-compose.yml index c111028067b..57441ff11e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,31 @@ -# Usage: -# docker-compose up -d +# This docker-compose file is used to run the reaction app in docker for development +# The local files are mounted into the created container. +# Usage: docker-compose up [-d] -reaction: - image: reactioncommerce/reaction:latest - links: - - mongo - ports: - - "80:3000" - environment: - ROOT_URL: "http://localhost" - MONGO_URL: "mongodb://mongo:27017/reaction" +version: '3.4' + +services: + reaction: + build: + context: . + target: meteor-dev + command: "reaction" + depends_on: + - mongo + environment: + ROOT_URL: "http://localhost" + MONGO_URL: "mongodb://mongo:27017/reaction" + ports: + - "3000:3000" + volumes: + - .:/opt/reaction/src + + mongo: + image: mongo:3.4 + command: mongod --storageEngine=wiredTiger + volumes: + - mongo-db:/data/db + +volumes: + mongo-db: -mongo: - image: mongo:latest - command: mongod --storageEngine=wiredTiger --bind_ip_all diff --git a/imports/plugins/core/accounts/client/components/loginInline.js b/imports/plugins/core/accounts/client/components/loginInline.js index 2c7b920a839..b360f88aaf4 100644 --- a/imports/plugins/core/accounts/client/components/loginInline.js +++ b/imports/plugins/core/accounts/client/components/loginInline.js @@ -2,6 +2,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { Reaction } from "/client/api"; import { Components } from "@reactioncommerce/reaction-components"; +import { ValidEmail } from "/lib/api"; /** * @summary React component to log in form in line @@ -27,7 +28,8 @@ class LoginInline extends Component { super(props); this.state = { - email: "" + email: "", + isValid: true }; } @@ -41,16 +43,36 @@ class LoginInline extends Component { */ handleFieldChange = (event, value, field) => { this.setState({ - [field]: value + [field]: value, + isValid: true }); }; handleSubmit = (event) => { - this.props.handleEmailSubmit(event, this.state.email); + event.preventDefault(); + + if (!ValidEmail(this.state.email)) { + this.setState({ + isValid: false + }); + } else { + this.setState({ + isValid: true + }); + this.props.handleEmailSubmit(event, this.state.email); + } } render() { if (this.props.renderEmailForm) { + const validation = { + messages: { + email: { + message: "Email is not valid", + i18nKeyMessage: "checkoutLogin.invalidEmail" + } + } + }; return (
@@ -64,6 +86,8 @@ class LoginInline extends Component { type="email" value={this.state.email} onChange={this.handleFieldChange} + isValid={this.state.isValid} + validation={validation} /> ); diff --git a/imports/plugins/core/accounts/client/components/verifyAccount.js b/imports/plugins/core/accounts/client/components/verifyAccount.js new file mode 100644 index 00000000000..ab8c9168339 --- /dev/null +++ b/imports/plugins/core/accounts/client/components/verifyAccount.js @@ -0,0 +1,37 @@ +import React from "react"; +import PropType from "prop-types"; +import { Components } from "@reactioncommerce/reaction-components"; +import classnames from "classnames"; + +const VerifyAccount = ({ error }) => { + const classNames = classnames({ + "fa": true, + "fa-times-circle-o": !!error, + "fa-check-circle-o": !error + }); + + const style = { + color: error ? "#f33" : "#49da49", + fontSize: "8rem" + }; + + return ( +
+
+ +

+ +

+
+
+ ); +}; + +VerifyAccount.propTypes = { + error: PropType.object +}; + +export default VerifyAccount; diff --git a/imports/plugins/core/accounts/client/containers/accountsDashboardContainer.js b/imports/plugins/core/accounts/client/containers/accountsDashboardContainer.js index 963a8348f4c..efc29053b55 100644 --- a/imports/plugins/core/accounts/client/containers/accountsDashboardContainer.js +++ b/imports/plugins/core/accounts/client/containers/accountsDashboardContainer.js @@ -91,7 +91,7 @@ const handlers = { const composer = (props, onData) => { const shopId = Reaction.getShopId(); const adminUserSub = Meteor.subscribe("Accounts", null); - const grpSub = Meteor.subscribe("Groups"); + const grpSub = Meteor.subscribe("Groups", { shopId }); if (adminUserSub.ready() && grpSub.ready()) { const groups = Groups.find({ diff --git a/imports/plugins/core/accounts/client/containers/passwordOverlay.js b/imports/plugins/core/accounts/client/containers/passwordOverlay.js index 79ee6267308..916087f8994 100644 --- a/imports/plugins/core/accounts/client/containers/passwordOverlay.js +++ b/imports/plugins/core/accounts/client/containers/passwordOverlay.js @@ -1,12 +1,13 @@ import _ from "lodash"; import React, { Component } from "react"; import PropTypes from "prop-types"; -import { Components, registerComponent } from "@reactioncommerce/reaction-components"; import { Accounts } from "meteor/accounts-base"; +import { Meteor } from "meteor/meteor"; import { Random } from "meteor/random"; +import { Components, registerComponent } from "@reactioncommerce/reaction-components"; import { Reaction } from "/client/api"; -import UpdatePasswordOverlay from "../components/updatePasswordOverlay"; import { LoginFormValidation } from "/lib/api"; +import UpdatePasswordOverlay from "../components/updatePasswordOverlay"; const wrapComponent = (Comp) => ( class UpdatePasswordOverlayContainer extends Component { @@ -73,6 +74,9 @@ const wrapComponent = (Comp) => ( } }); } else { + // Now that Meteor.users is verified, we should do the same with the Accounts collection + Meteor.call("accounts/verifyAccount"); + this.props.callback(); this.setState({ diff --git a/imports/plugins/core/accounts/client/containers/userOrdersListContainer.js b/imports/plugins/core/accounts/client/containers/userOrdersListContainer.js index 72f40d9c830..d2550e1638f 100644 --- a/imports/plugins/core/accounts/client/containers/userOrdersListContainer.js +++ b/imports/plugins/core/accounts/client/containers/userOrdersListContainer.js @@ -1,37 +1,9 @@ -import { compose, withProps } from "recompose"; +import { compose } from "recompose"; import { Meteor } from "meteor/meteor"; import { Router } from "/client/modules/router/"; -import { Media } from "/lib/collections"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import OrdersList from "../components/ordersList"; - -const handlers = {}; - -handlers.handleDisplayMedia = (item) => { - const variantId = item.variants._id; - const { productId } = item; - - const variantImage = Media.findOne({ - "metadata.variantId": variantId, - "metadata.productId": productId - }); - - if (variantImage) { - return variantImage; - } - - const defaultImage = Media.findOne({ - "metadata.productId": productId, - "metadata.priority": 0 - }); - - if (defaultImage) { - return defaultImage; - } - return false; -}; - function composer(props, onData) { // Get user order from props const { orders } = props; @@ -44,7 +16,7 @@ function composer(props, onData) { if (orders.length > 0) { orders.map((order) => { - const imageSub = Meteor.subscribe("CartImages", order.items); + const imageSub = Meteor.subscribe("OrderImages", order._id); const orderSummary = { quantityTotal: order.getCount(), subtotal: order.getSubTotal(), @@ -55,15 +27,13 @@ function composer(props, onData) { shipping: order.shipping }; if (imageSub.ready()) { - const productImages = Media.find().fetch(); const orderId = order._id; const orderInfo = { shops: order.getShopSummary(), order, orderId, orderSummary, - paymentMethods: order.getUniquePaymentMethods(), - productImages + paymentMethods: order.getUniquePaymentMethods() }; allOrdersInfo.push(orderInfo); } @@ -83,11 +53,7 @@ function composer(props, onData) { registerComponent("OrdersList", OrdersList, [ - withProps(handlers), composeWithTracker(composer) ]); -export default compose( - withProps(handlers), - composeWithTracker(composer) -)(OrdersList); +export default compose(composeWithTracker(composer))(OrdersList); diff --git a/imports/plugins/core/accounts/client/containers/verifyAccount.js b/imports/plugins/core/accounts/client/containers/verifyAccount.js new file mode 100644 index 00000000000..2db9ade608c --- /dev/null +++ b/imports/plugins/core/accounts/client/containers/verifyAccount.js @@ -0,0 +1,75 @@ +import { Accounts } from "meteor/accounts-base"; +import { ReactiveVar } from "meteor/reactive-var"; +import { Meteor } from "meteor/meteor"; +import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; +import VerifyAccount from "../components/verifyAccount"; +import { Reaction } from "/client/api"; + + +const verified = new ReactiveVar(null); + +Accounts.onEmailVerificationLink((token, done) => { + Accounts.verifyEmail(token, (error) => { + if (error) { + verified.set({ + error: { + reason: error.reason + // no i18nKey for framework errors for now + } + }); + } else { + verified.set(true); + } + Reaction.Router.go("account/verify"); + done(); + }); +}); + +function wrapper(props, onData) { + Meteor.setTimeout(() => { + if (!verified.get()) { + onData(null, { + error: { + defaultValue: "Verification timed out. Probably you've already been verified successfully.", + i18nKey: "accountsUI.error.verifyTimeout" + } + }); + Meteor.setTimeout(() => { + Reaction.Router.go("/"); + }, 2000); + } + }, 5000); + + const user = Meteor.user(); + if (user && verified.get() === true) { + for (const email of user.emails) { + if (email.verified === true) { + Meteor.call("accounts/verifyAccount", (error, affectedDocs) => { + if (error) { + onData(null, { + error: { + reason: error.reason + // no i18nKey for framework errors for now + } + }); + return; + } + if (affectedDocs === 0) { + onData(null, { + error: { + reason: "Couldn't verify email address.", + i18nKey: "accountsUI.error.verifyEmailAddressNotFound" + } + }); + return; + } + // Success + onData(null, {}); + }); + } + } + } + onData(null, verified.get()); +} + +registerComponent("VerifyAccount", VerifyAccount, composeWithTracker(wrapper)); diff --git a/imports/plugins/core/accounts/client/index.js b/imports/plugins/core/accounts/client/index.js index 810c825fb02..c95f4cd6b37 100644 --- a/imports/plugins/core/accounts/client/index.js +++ b/imports/plugins/core/accounts/client/index.js @@ -28,6 +28,7 @@ export { default as MainDropdownContainer } from "./containers/mainDropdown"; export { default as MessagesContainer } from "./containers/messages"; export { default as UpdatePasswordOverlayContainer } from "./containers/passwordOverlay"; export { default as LoginInlineContainer } from "./containers/loginInline"; +export { default as VerifyAccount } from "./containers/verifyAccount"; import "./templates/accounts.html"; @@ -58,5 +59,3 @@ import "./templates/profile/userOrdersList.html"; import "./templates/profile/userOrdersList.js"; import "./templates/updatePassword/updatePassword.html"; import "./templates/updatePassword/updatePassword.js"; -import "./templates/verify/verifyAccount.html"; -import "./templates/verify/verifyAccount.js"; diff --git a/imports/plugins/core/accounts/client/templates/addressBook/add/add.js b/imports/plugins/core/accounts/client/templates/addressBook/add/add.js index 73c40979db2..b62a48ce390 100644 --- a/imports/plugins/core/accounts/client/templates/addressBook/add/add.js +++ b/imports/plugins/core/accounts/client/templates/addressBook/add/add.js @@ -89,24 +89,24 @@ Template.addressBookAdd.helpers({ AutoForm.hooks({ addressBookAddForm: { onSubmit(insertDoc) { - const that = this; - this.event.preventDefault(); - const addressBook = $(this.template.firstNode).closest(".address-book"); + const { done, event, template } = this; // provided by AutoForm + event.preventDefault(); + const addressBook = $(template.firstNode).closest(".address-book"); + + function handleError(error) { + Alerts.toast(i18next.t("addressBookAdd.failedToAddAddress", { err: error.message }), "error"); + done(error); + } Meteor.call("accounts/validateAddress", insertDoc, (err, res) => { + if (err) return handleError(err); + // if the address is validated OR the address has already been through the validation process, pass it on if (res.validated) { - Meteor.call("accounts/addressBookAdd", insertDoc, (error, result) => { - if (error) { - Alerts.toast(i18next.t("addressBookAdd.failedToAddAddress", { err: error.message }), "error"); - that.done(new Error("Failed to add address: ", error)); - return false; - } - if (result) { - that.done(); - addressBook.trigger($.Event("showMainView")); - return true; - } + Meteor.call("accounts/addressBookAdd", insertDoc, (error) => { + if (error) return handleError(error); + done(); + addressBook.trigger($.Event("showMainView")); // Show the grid }); } else { // set addressState and kick it back to review diff --git a/imports/plugins/core/accounts/client/templates/addressBook/edit/edit.js b/imports/plugins/core/accounts/client/templates/addressBook/edit/edit.js index cc53bb7289d..269ddf5c93f 100644 --- a/imports/plugins/core/accounts/client/templates/addressBook/edit/edit.js +++ b/imports/plugins/core/accounts/client/templates/addressBook/edit/edit.js @@ -5,7 +5,6 @@ import { Template } from "meteor/templating"; import { AutoForm } from "meteor/aldeed:autoform"; import { i18next } from "/client/api"; - function setWorkingAddress(address) { if (address.fullName) { const fullName = $("input[name='fullName']"); @@ -49,7 +48,6 @@ function setWorkingAddress(address) { } } - Template.addressBookEdit.onRendered(() => { const addressState = Session.get("addressState"); if (addressState.address) { @@ -64,25 +62,24 @@ Template.addressBookEdit.onRendered(() => { AutoForm.hooks({ addressBookEditForm: { onSubmit(insertDoc) { - const that = this; - this.event.preventDefault(); - const addressBook = $(this.template.firstNode).closest(".address-book"); + const { done, event, template } = this; // provided by AutoForm + event.preventDefault(); + const addressBook = $(template.firstNode).closest(".address-book"); + + function handleError(error) { + Alerts.toast(i18next.t("addressBookEdit.somethingWentWrong", { err: error.message }), "error"); + done(error); + } + + Meteor.call("accounts/validateAddress", insertDoc, (err, res) => { + if (err) return handleError(err); - Meteor.call("accounts/validateAddress", insertDoc, function (err, res) { // if the address is validated OR the address has already been through the validation process, pass it on if (res.validated) { - Meteor.call("accounts/addressBookUpdate", insertDoc, (error, result) => { - if (error) { - Alerts.toast(i18next.t("addressBookEdit.somethingWentWrong", { err: error.message }), "error"); - this.done(new Error(error)); - return false; - } - if (result) { - that.done(); - - // Show the grid - addressBook.trigger($.Event("showMainView")); - } + Meteor.call("accounts/addressBookUpdate", insertDoc, (error) => { + if (error) return handleError(error); + done(); + addressBook.trigger($.Event("showMainView")); // Show the grid }); } else { // set addressState and kick it back to review diff --git a/imports/plugins/core/accounts/client/templates/updatePassword/updatePassword.js b/imports/plugins/core/accounts/client/templates/updatePassword/updatePassword.js index 26a582956a2..18dc379b7f8 100644 --- a/imports/plugins/core/accounts/client/templates/updatePassword/updatePassword.js +++ b/imports/plugins/core/accounts/client/templates/updatePassword/updatePassword.js @@ -1,6 +1,5 @@ import { Accounts } from "meteor/accounts-base"; import { Template } from "meteor/templating"; -import { Meteor } from "meteor/meteor"; import { $ } from "meteor/jquery"; import { Random } from "meteor/random"; import { Blaze } from "meteor/blaze"; @@ -26,7 +25,6 @@ Accounts.onResetPasswordLink((token, done) => { * Accounts Event: onEnrollmentLink When a user uses an enrollment link */ Accounts.onEnrollmentLink((token, done) => { - Meteor.call("accounts/verifyAccount", "", token); Blaze.renderWithData(Template.loginFormUpdatePasswordOverlay, { token, callback: done, @@ -35,13 +33,6 @@ Accounts.onEnrollmentLink((token, done) => { }, $("body").get(0)); }); -/** - * Accounts Event: onEmailVerificationLink When a user uses an verification link - */ -Accounts.onEmailVerificationLink((token, done) => { - Accounts.verifyEmail(token); - done(); -}); // ---------------------------------------------------------------------------- // /** diff --git a/imports/plugins/core/accounts/client/templates/verify/verifyAccount.html b/imports/plugins/core/accounts/client/templates/verify/verifyAccount.html deleted file mode 100644 index 3af167dabae..00000000000 --- a/imports/plugins/core/accounts/client/templates/verify/verifyAccount.html +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/imports/plugins/core/accounts/client/templates/verify/verifyAccount.js b/imports/plugins/core/accounts/client/templates/verify/verifyAccount.js deleted file mode 100644 index 840119e8eb0..00000000000 --- a/imports/plugins/core/accounts/client/templates/verify/verifyAccount.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Meteor } from "meteor/meteor"; -import { Template } from "meteor/templating"; -import { ReactiveVar } from "meteor/reactive-var"; -import { Reaction } from "/client/api"; - -Template.verifyAccount.onCreated(() => { - const template = Template.instance(); - template.verified = ReactiveVar(false); - const email = Reaction.Router.getQueryParam("email"); - Meteor.call("accounts/verifyAccount", email, (error, result) => { - if (error) { - throw new Meteor.Error("account-verification-error", error); - } - return template.verified.set(result); - }); -}); - -Template.verifyAccount.helpers({ - verificationStatus() { - return Template.instance().verified.get(); - } -}); diff --git a/imports/plugins/core/accounts/register.js b/imports/plugins/core/accounts/register.js index c8f35543cb6..bf6537889cb 100644 --- a/imports/plugins/core/accounts/register.js +++ b/imports/plugins/core/accounts/register.js @@ -24,11 +24,11 @@ Reaction.registerPackage({ workflow: "coreAccountsWorkflow", priority: 1 }, { - route: "/account/profile/verify:email?", + route: "/account/profile/verify", label: "Account Verify", name: "account/verify", workflow: "coreAccountsWorkflow", - template: "verifyAccount" + template: "VerifyAccount" }, { label: "Account Settings", icon: "fa fa-sign-in", diff --git a/imports/plugins/core/catalog/server/i18n/en.json b/imports/plugins/core/catalog/server/i18n/en.json index f500e00acfd..99ed92cdbe5 100644 --- a/imports/plugins/core/catalog/server/i18n/en.json +++ b/imports/plugins/core/catalog/server/i18n/en.json @@ -4,6 +4,7 @@ "translation": { "reaction-catalog": { "admin": { + "catalogProductPublishSuccess": "Product published to catalog", "shortcut": { "catalogLabel": "Catalog", "catalogTitle": "Catalog" diff --git a/imports/plugins/core/catalog/server/index.js b/imports/plugins/core/catalog/server/index.js index 3979f964b5a..0c507c4d963 100644 --- a/imports/plugins/core/catalog/server/index.js +++ b/imports/plugins/core/catalog/server/index.js @@ -1 +1,2 @@ import "./i18n"; +import "./methods/catalog"; diff --git a/imports/plugins/core/catalog/server/methods/catalog.app-test.js b/imports/plugins/core/catalog/server/methods/catalog.app-test.js new file mode 100644 index 00000000000..26b42992ad5 --- /dev/null +++ b/imports/plugins/core/catalog/server/methods/catalog.app-test.js @@ -0,0 +1,142 @@ +/* eslint dot-notation: 0 */ +/* eslint prefer-arrow-callback:0 */ +import { Random } from "meteor/random"; +import { expect } from "meteor/practicalmeteor:chai"; +import { sinon } from "meteor/practicalmeteor:sinon"; +import { Roles } from "meteor/alanning:roles"; +import { createActiveShop } from "/server/imports/fixtures/shops"; +import { Reaction } from "/server/api"; +import * as Collections from "/lib/collections"; +import Fixtures from "/server/imports/fixtures"; +import { PublicationCollector } from "meteor/johanbrook:publication-collector"; +import { RevisionApi } from "/imports/plugins/core/revisions/lib/api/revisions"; +import { publishProductToCatalog } from "./catalog"; + +Fixtures(); + +describe("Catalog", function () { + const shopId = Random.id(); + let sandbox; + + beforeEach(function () { + createActiveShop({ _id: shopId }); + sandbox = sinon.sandbox.create(); + sandbox.stub(RevisionApi, "isRevisionControlEnabled", () => true); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe("with products", function () { + const priceRangeA = { + range: "1.00 - 12.99", + min: 1.00, + max: 12.99 + }; + + const priceRangeB = { + range: "12.99 - 19.99", + min: 12.99, + max: 19.99 + }; + + beforeEach(function (done) { + Collections.Products.direct.remove({}); + Collections.Catalog.remove({}); + + // a product with price range A, and not visible + const id1 = Collections.Products.insert({ + ancestors: [], + title: "My Little Pony", + shopId, + type: "simple", + price: priceRangeA, + isVisible: false, + isLowQuantity: false, + isSoldOut: false, + isBackorder: false + }); + // a product with price range B, and visible + const id2 = Collections.Products.insert({ + ancestors: [], + title: "Shopkins - Peachy", + shopId, + price: priceRangeB, + type: "simple", + isVisible: true, + isLowQuantity: false, + isSoldOut: false, + isBackorder: false + }); + // a product with price range A, and visible + const id3 = Collections.Products.insert({ + ancestors: [], + title: "Fresh Tomatoes", + shopId, + price: priceRangeA, + type: "simple", + isVisible: true, + isLowQuantity: false, + isSoldOut: false, + isBackorder: false + }); + + Promise.all([ + publishProductToCatalog(id1), + publishProductToCatalog(id2), + publishProductToCatalog(id3) + ]).then(() => { + done(); + }); + }); + + describe("Collection", function () { + it("should return 3 products from the Catalog", function () { + const products = Collections.Catalog.find({}).fetch(); + expect(products.length).to.equal(3); + }); + }); + + describe("Publication", function () { + it("should return 2 products from Products/get", function (done) { + const productScrollLimit = 24; + sandbox.stub(Reaction, "getShopId", () => shopId); + sandbox.stub(Roles, "userIsInRole", () => false); + + const collector = new PublicationCollector({ userId: Random.id() }); + let isDone = false; + + collector.collect("Products/grid", productScrollLimit, undefined, {}, (collections) => { + const products = collections.Catalog; + expect(products.length).to.equal(2); + + if (!isDone) { + isDone = true; + done(); + } + }); + }); + + it("should return one product in price.min query", function (done) { + const productScrollLimit = 24; + const filters = { "price.min": "2.00" }; + sandbox.stub(Reaction, "getShopId", () => shopId); + sandbox.stub(Roles, "userIsInRole", () => false); + + const collector = new PublicationCollector({ userId: Random.id() }); + let isDone = false; + + collector.collect("Products/grid", productScrollLimit, filters, {}, (collections) => { + const products = collections.Catalog; + expect(products.length).to.equal(1); + + if (!isDone) { + isDone = true; + done(); + } + }); + }); + }); + }); +}); diff --git a/imports/plugins/core/catalog/server/methods/catalog.js b/imports/plugins/core/catalog/server/methods/catalog.js new file mode 100644 index 00000000000..4075c82e39e --- /dev/null +++ b/imports/plugins/core/catalog/server/methods/catalog.js @@ -0,0 +1,241 @@ +import { Meteor } from "meteor/meteor"; +import { check, Match } from "meteor/check"; +import { Products, Catalog as CatalogCollection } from "/lib/collections"; +import { Logger, Reaction } from "/server/api"; +import { Media } from "/imports/plugins/core/files/server"; +import { ProductRevision as Catalog } from "/imports/plugins/core/revisions/server/hooks"; + +/** + * @method isSoldOut + * @summary We are to stop accepting new orders if product is marked as `isSoldOut`. + * @memberof Catalog + * @param {Array} variants - Array with top-level variants + * @return {Boolean} true if summary product quantity is zero. + */ +export function isSoldOut(variants) { + return variants.every((variant) => { + if (variant.inventoryManagement) { + return Catalog.getVariantQuantity(variant) <= 0; + } + return false; + }); +} + +/** + * @method isLowQuantity + * @summary If at least one of the variants is less than the threshold, then function returns `true` + * @memberof Catalog + * @param {Array} variants - array of child variants + * @return {boolean} low quantity or not + */ +export function isLowQuantity(variants) { + return variants.some((variant) => { + const quantity = Catalog.getVariantQuantity(variant); + // we need to keep an eye on `inventoryPolicy` too and qty > 0 + if (variant.inventoryManagement && variant.inventoryPolicy && quantity) { + return quantity <= variant.lowInventoryWarningThreshold; + } + return false; + }); +} + +/** + * @method isBackorder + * @summary Is products variants is still available to be ordered after summary variants quantity is zero + * @memberof Catalog + * @param {Array} variants - array with variant objects + * @return {boolean} is backorder allowed or not for a product + */ +export function isBackorder(variants) { + return variants.every((variant) => !variant.inventoryPolicy && variant.inventoryManagement && + variant.inventoryQuantity === 0); +} + +/** + * @method publishProductToCatalog + * @summary Publish a product to the Catalog + * @memberof Catalog + * @param {string} productId - A string product id + * @return {boolean} true on successful publish, false if publish was unsuccessful + */ +export async function publishProductToCatalog(productId) { + check(productId, String); + + // Find the product by id + let product = Products.findOne({ + $or: [ + { _id: productId }, + { ancestors: { $in: [productId] } } + ] + }); + + // Stop if a product could not be found + if (!product) { + Logger.info("Cannot publish product to catalog"); + return false; + } + + // If the product has ancestors, then find to top product document + if (Array.isArray(product.ancestors) && product.ancestors.length) { + product = Products.findOne({ + _id: product.ancestors[0] + }); + } + + // Get variants of the product + const variants = Products.find({ + ancestors: { + $in: [productId] + } + }).fetch(); + + // Get Media for the product + const mediaArray = await Media.find({ + "metadata.productId": productId, + "metadata.toGrid": 1, + "metadata.workflow": { $nin: ["archived", "unpublished"] } + }, { + sort: { "metadata.priority": 1, "uploadedAt": 1 } + }); + + // Denormalize media + const productMedia = mediaArray.map((media) => ({ + metadata: media.metadata, + thumbnail: `${media.url({ store: "thumbnail" })}`, + small: `${media.url({ store: "small" })}`, + medium: `${media.url({ store: "medium" })}`, + large: `${media.url({ store: "large" })}`, + image: `${media.url({ store: "image" })}` + })); + + // Denormalize product fields + product.media = productMedia; + product.type = "product-simple"; + product.isSoldOut = isSoldOut(variants); + product.isBackorder = isBackorder(variants); + product.isLowQuantity = isLowQuantity(variants); + product.variants = variants.map((variant) => { + const { inventoryQuantity, ...v } = variant; + return v; + }); + + // Insert/update catalog document + const result = CatalogCollection.upsert({ + _id: productId + }, { + $set: product + }); + + return result && result.numberAffected === 1; +} + +/** + * @method publishProductsToCatalog + * @summary Publish one or more products to the Catalog + * @memberof Catalog + * @param {string|array} productIds - A string product id or an array of product ids + * @return {boolean} true on successful publish for all documents, false if one ore more fail to publish + */ +export function publishProductsToCatalog(productIds) { + check(productIds, Match.OneOf(String, Array)); + + let ids = productIds; + if (typeof ids === "string") { + ids = [productIds]; + } + + return ids.every(async (productId) => await publishProductToCatalog(productId)); +} + +/** + * @method publishProductInventoryAdjustments + * @summary Publish inventory updates for a single product to the Catalog + * @memberof Catalog + * @param {string} productId - A string product id + * @return {boolean} true on success, false on failure + */ +export function publishProductInventoryAdjustments(productId) { + check(productId, Match.OneOf(String, Array)); + + const catalogProduct = CatalogCollection.findOne({ + _id: productId + }); + + if (!catalogProduct) { + Logger.info("Cannot publish inventory changes to catalog product"); + return false; + } + + const variants = Products.find({ + ancestors: { + $in: [productId] + } + }).fetch(); + + const update = { + isSoldOut: isSoldOut(variants), + isBackorder: isBackorder(variants), + isLowQuantity: isLowQuantity(variants) + }; + + // Only apply changes of one these fields have changed + if ( + update.isSoldOut !== catalogProduct.isSoldOut || + update.isBackorder !== catalogProduct.isBackorder || + update.isLowQuantity !== catalogProduct.isLowQuantity + ) { + const result = CatalogCollection.update({ + _id: productId + }, { + $set: update + }); + + return result; + } + + return false; +} + +Meteor.methods({ + "catalog/publish/products": (productIds) => { + check(productIds, Match.OneOf(String, Array)); + + // Ensure user has createProduct permission for active shop + if (!Reaction.hasPermission("createProduct")) { + Logger.error("Access Denied"); + throw new Meteor.Error("access-denied", "Access Denied"); + } + + // Convert productIds if it's a string + let ids = productIds; + if (typeof ids === "string") { + ids = [productIds]; + } + + // Find all products + const productsToPublish = Products.find({ + _id: { $in: ids } + }).fetch(); + + if (Array.isArray(productsToPublish)) { + const canUpdatePrimaryShopProducts = Reaction.hasPermission("createProduct", this.userId, Reaction.getPrimaryShopId()); + + const publisableProductIds = productsToPublish + // Only allow users to publish products for shops they permissions to createProductsFor + // If the user can createProducts on the main shop, they can publish products for all shops to the catalog. + .filter((product) => Reaction.hasPermission("createProduct", this.userId, product.shopId) || canUpdatePrimaryShopProducts) + .map((product) => product._id); + + const success = publishProductsToCatalog(publisableProductIds); + + if (!success) { + Logger.error("Some Products could not be published to the Catalog."); + throw new Meteor.Error("server-error", "Some Products could not be published to the Catalog."); + } + + return true; + } + + return false; + } +}); diff --git a/imports/plugins/core/checkout/client/components/cartItems.js b/imports/plugins/core/checkout/client/components/cartItems.js index 087dcba1a3e..3a9ac52cd47 100644 --- a/imports/plugins/core/checkout/client/components/cartItems.js +++ b/imports/plugins/core/checkout/client/components/cartItems.js @@ -36,6 +36,8 @@ class CartItems extends Component { item } = this.props; + const mediaUrl = handleImage(item); + return (
- {handleImage(item) ? -
- + {mediaUrl ? +
+
:
diff --git a/imports/plugins/core/checkout/client/components/cartSubTotal.js b/imports/plugins/core/checkout/client/components/cartSubTotal.js index bfa17618ede..b27c17d0591 100644 --- a/imports/plugins/core/checkout/client/components/cartSubTotal.js +++ b/imports/plugins/core/checkout/client/components/cartSubTotal.js @@ -1,5 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; +import classnames from "classnames"; import { Components } from "@reactioncommerce/reaction-components"; class CartSubTotal extends Component { @@ -9,62 +10,87 @@ class CartSubTotal extends Component { cartShipping: PropTypes.string, cartSubTotal: PropTypes.string, cartTaxes: PropTypes.string, - cartTotal: PropTypes.string + cartTotal: PropTypes.string, + isLoading: PropTypes.bool + } + + constructor(props) { + super(props); + this.state = { + ...props + }; + } + + componentWillReceiveProps(nextProps) { + this.setState({ + ...nextProps + }); + } + + get source() { + return this.props.isLoading ? this.state : this.props; } validateDiscount() { - if (Number(this.props.cartDiscount) > 0) { + if (Number(this.source.cartDiscount) > 0) { return ( - + ); } } validateShipping() { - if (Number(this.props.cartShipping) > 0) { + if (Number(this.source.cartShipping) > 0) { return ( - + ); } } validateTaxes() { - if (Number(this.props.cartTaxes) > 0) { + if (Number(this.source.cartTaxes) > 0) { return ( - + ); } } render() { + const { isLoading } = this.props; + const tableClass = classnames({ + "table": true, + "table-condensed": true, + "loading": isLoading + }); return (
- + { isLoading && } +
- + - + {this.validateDiscount()} {this.validateShipping()} {this.validateTaxes()} - +
{this.props.cartCount}{this.source.cartCount}
diff --git a/imports/plugins/core/checkout/client/components/completedOrder.js b/imports/plugins/core/checkout/client/components/completedOrder.js index 590086ab1db..36486479b3c 100644 --- a/imports/plugins/core/checkout/client/components/completedOrder.js +++ b/imports/plugins/core/checkout/client/components/completedOrder.js @@ -13,11 +13,10 @@ import AddEmail from "./addEmail"; * @property {Array} shops - An Array contains information broken down by shop * @property {Object} orderSummary - An object containing the items making up the order summary * @property {Array} paymentMethod - An array of paymentMethod objects - * @property {Function} handleDisplayMedia - A function for displaying the product image * @property {Booleam} isProfilePage - A boolean value that checks if current page is user profile page * @return {Node} React node containing the top-level component for displaying the completed order/receipt page */ -const CompletedOrder = ({ order, shops, orderSummary, paymentMethods, handleDisplayMedia, isProfilePage }) => { +const CompletedOrder = ({ order, shops, orderSummary, paymentMethods, isProfilePage }) => { if (!order) { return ( ); @@ -107,7 +105,6 @@ const CompletedOrder = ({ order, shops, orderSummary, paymentMethods, handleDisp }; CompletedOrder.propTypes = { - handleDisplayMedia: PropTypes.func, isProfilePage: PropTypes.bool, order: PropTypes.object, orderSummary: PropTypes.object, diff --git a/imports/plugins/core/checkout/client/components/completedOrderItem.js b/imports/plugins/core/checkout/client/components/completedOrderItem.js index 416ad494c61..530274ac600 100644 --- a/imports/plugins/core/checkout/client/components/completedOrderItem.js +++ b/imports/plugins/core/checkout/client/components/completedOrderItem.js @@ -1,20 +1,20 @@ import React from "react"; import PropTypes from "prop-types"; +import { getPrimaryMediaForOrderItem } from "/lib/api"; import { Components, registerComponent } from "@reactioncommerce/reaction-components"; /** * @summary Shows the individual line items for a completed order * @param {Object} props - React PropTypes * @property {Object} item - An object representing each item on the order - * @property {Function} handleDisplayMedia - a function for displaying the proper product image * @return {Node} React node containing each line item on an order */ -const CompletedOrderItem = ({ item, handleDisplayMedia }) => ( +const CompletedOrderItem = ({ item }) => (
@@ -25,9 +25,7 @@ const CompletedOrderItem = ({ item, handleDisplayMedia }) => (
); - CompletedOrderItem.propTypes = { - handleDisplayMedia: PropTypes.func, item: PropTypes.object }; diff --git a/imports/plugins/core/checkout/client/components/completedShopOrders.js b/imports/plugins/core/checkout/client/components/completedShopOrders.js index bcaafb338c7..1c3a0670b1b 100644 --- a/imports/plugins/core/checkout/client/components/completedShopOrders.js +++ b/imports/plugins/core/checkout/client/components/completedShopOrders.js @@ -8,11 +8,10 @@ import CompletedOrderItem from "./completedOrderItem"; * @param {Object} props - React PropTypes * @property {String} shopName - The name of the shop * @property {Array} items - an array of individual items for this shop - * @property {Function} handleDisplayMedia - A function for displaying product images * @property {boolean} isProfilePage - Checks if current page is profile page * @return {Node} React node containing the break down of the order by Shop */ -const CompletedShopOrders = ({ shopName, items, handleDisplayMedia, shippingMethod, isProfilePage }) => { +const CompletedShopOrders = ({ shopName, items, shippingMethod, isProfilePage }) => { const shippingName = isProfilePage ? ( @@ -30,7 +29,7 @@ const CompletedShopOrders = ({ shopName, items, handleDisplayMedia, shippingMeth
- {items.map((item) => )} + {items.map((item) => )}
{/* This is the left side / main content */} @@ -39,7 +38,6 @@ const CompletedShopOrders = ({ shopName, items, handleDisplayMedia, shippingMeth }; CompletedShopOrders.propTypes = { - handleDisplayMedia: PropTypes.func, isProfilePage: PropTypes.bool, items: PropTypes.array, order: PropTypes.object, diff --git a/imports/plugins/core/checkout/client/containers/cartDrawerContainer.js b/imports/plugins/core/checkout/client/containers/cartDrawerContainer.js index 01bc2043888..3083d16aa4d 100644 --- a/imports/plugins/core/checkout/client/containers/cartDrawerContainer.js +++ b/imports/plugins/core/checkout/client/containers/cartDrawerContainer.js @@ -3,19 +3,16 @@ import { registerComponent, composeWithTracker } from "@reactioncommerce/reactio import { $ } from "meteor/jquery"; import { Session } from "meteor/session"; import { Meteor } from "meteor/meteor"; -import { Cart, Media } from "/lib/collections"; +import { Cart } from "/lib/collections"; +import { getPrimaryMediaForOrderItem, ReactionProduct } from "/lib/api"; import { Reaction } from "/client/api"; import CartDrawer from "../components/cartDrawer"; -import { ReactionProduct } from "/lib/api"; // event handlers to pass in as props const handlers = { handleImage(item) { - const { defaultImage } = item; - if (defaultImage && defaultImage.url({ store: "small" })) { - return defaultImage; - } - return false; + const media = getPrimaryMediaForOrderItem(item); + return media && media.url({ store: "small" }); }, /** @@ -73,26 +70,13 @@ const handlers = { // reactive Tracker wrapped function function composer(props, onData) { const userId = Meteor.userId(); - let shopId = Reaction.getPrimaryShopId(); - if (Reaction.marketplace.merchantCarts) { - shopId = Reaction.getShopId(); - } - let productItems = Cart.findOne({ userId, shopId }).items; - let defaultImage; + const shopId = Reaction.marketplace.merchantCarts ? Reaction.getShopId() : Reaction.getPrimaryShopId(); + const cart = Cart.findOne({ userId, shopId }); + if (!cart) return; - productItems = productItems.map((item) => { - Meteor.subscribe("CartItemImage", item); - defaultImage = Media.findOne({ - "metadata.variantId": item.variants._id - }); - if (defaultImage) { - return Object.assign({}, item, { defaultImage }); - } - defaultImage = Media.findOne({ - "metadata.productId": item.productId - }); - return Object.assign({}, item, { defaultImage }); - }); + Meteor.subscribe("CartImages", cart._id); + + const productItems = cart && cart.items; onData(null, { productItems }); diff --git a/imports/plugins/core/checkout/client/containers/cartSubTotalContainer.js b/imports/plugins/core/checkout/client/containers/cartSubTotalContainer.js index 1f26128f9be..46dd769c711 100644 --- a/imports/plugins/core/checkout/client/containers/cartSubTotalContainer.js +++ b/imports/plugins/core/checkout/client/containers/cartSubTotalContainer.js @@ -1,21 +1,40 @@ +import { setTimeout } from "timers"; +import { compose } from "recompose"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import { Cart } from "/lib/collections"; import CartSubTotal from "../components/cartSubTotal"; function composer(props, onData) { - const cart = Cart.findOne(); - if (cart) { - onData(null, { - cartSubTotal: cart.getSubTotal(), - cartCount: cart.getCount(), - cartShipping: cart.getShippingTotal(), - cartDiscount: cart.getDiscounts(), - cartTaxes: cart.getTaxTotal(), - cartTotal: cart.getTotal() - }); - } + onData(null, { + isLoading: true + }); + + let stopped = false; + setTimeout(() => { + if (stopped) return; + const cart = Cart.findOne(); + if (cart) { + onData(null, { + cartSubTotal: cart.getSubTotal(), + cartCount: cart.getCount(), + cartShipping: cart.getShippingTotal(), + cartDiscount: cart.getDiscounts(), + cartTaxes: cart.getTaxTotal(), + cartTotal: cart.getTotal(), + isLoading: false + }); + } + }, 200); + + return () => { + stopped = true; + }; } -registerComponent("CartSubTotal", CartSubTotal, composeWithTracker(composer)); +const hocs = [ + composeWithTracker(composer) +]; + +registerComponent("CartSubTotal", CartSubTotal, hocs); -export default composeWithTracker(composer)(CartSubTotal); +export default compose(...hocs)(CartSubTotal); diff --git a/imports/plugins/core/checkout/client/containers/completedOrderContainer.js b/imports/plugins/core/checkout/client/containers/completedOrderContainer.js index 8d26ac2d540..0bc87e12998 100644 --- a/imports/plugins/core/checkout/client/containers/completedOrderContainer.js +++ b/imports/plugins/core/checkout/client/containers/completedOrderContainer.js @@ -1,38 +1,11 @@ -import { compose, withProps } from "recompose"; +import { compose } from "recompose"; import { Meteor } from "meteor/meteor"; import { Session } from "meteor/session"; -import { Orders, Media } from "/lib/collections"; +import { Orders } from "/lib/collections"; import { Reaction } from "/client/api"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import CompletedOrder from "../components/completedOrder"; - -const handlers = {}; - -handlers.handleDisplayMedia = (item) => { - const variantId = item.variants._id; - const { productId } = item; - - const variantImage = Media.findOne({ - "metadata.variantId": variantId, - "metadata.productId": productId - }); - - if (variantImage) { - return variantImage; - } - - const defaultImage = Media.findOne({ - "metadata.productId": productId, - "metadata.priority": 0 - }); - - if (defaultImage) { - return defaultImage; - } - return false; -}; - function composer(props, onData) { // The Cart subscription does not update when you delete the original record // but don't change parameters so we need to re-init that subscription here. @@ -50,7 +23,7 @@ function composer(props, onData) { }); if (order) { - const imageSub = Meteor.subscribe("CartImages", order.items); + const imageSub = Meteor.subscribe("OrderImages", order._id); const orderSummary = { quantityTotal: order.getCount(), @@ -63,15 +36,12 @@ function composer(props, onData) { }; if (imageSub.ready()) { - const productImages = Media.find().fetch(); - onData(null, { isProfilePage: false, shops: order.getShopSummary(), order, orderSummary, - paymentMethods: order.getUniquePaymentMethods(), - productImages + paymentMethods: order.getUniquePaymentMethods() }); } } else { @@ -82,13 +52,8 @@ function composer(props, onData) { } } - registerComponent("CompletedOrder", CompletedOrder, [ - withProps(handlers), composeWithTracker(composer) ]); -export default compose( - withProps(handlers), - composeWithTracker(composer) -)(CompletedOrder); +export default compose(composeWithTracker(composer))(CompletedOrder); diff --git a/imports/plugins/core/checkout/client/methods/cart.js b/imports/plugins/core/checkout/client/methods/cart.js index 14ea077a059..402b4975e97 100644 --- a/imports/plugins/core/checkout/client/methods/cart.js +++ b/imports/plugins/core/checkout/client/methods/cart.js @@ -11,7 +11,7 @@ Meteor.methods({ // Under consideration for deprecation and migrating other payment Packages // to payments-stripe style methods "cart/submitPayment"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + check(paymentMethod, Object); const checkoutCart = Cart.findOne({ userId: Meteor.userId() }); diff --git a/imports/plugins/core/checkout/client/templates/cartDrawer/cartItems/cartItems.js b/imports/plugins/core/checkout/client/templates/cartDrawer/cartItems/cartItems.js deleted file mode 100644 index e713cfbb49f..00000000000 --- a/imports/plugins/core/checkout/client/templates/cartDrawer/cartItems/cartItems.js +++ /dev/null @@ -1,35 +0,0 @@ -import _ from "lodash"; -import { Template } from "meteor/templating"; -import { Media } from "/lib/collections"; - -/** - * cartDrawerItems helpers - * - * @provides media - * @returns default product image - */ -Template.cartDrawerItems.helpers({ - product() { - return this; - }, - media() { - const product = this; - let defaultImage = Media.findOne({ - "metadata.variantId": this.variants._id - }); - - if (defaultImage) { - return defaultImage; - } else if (product) { - _.some(product.variants, (variant) => { - if (variant) { - defaultImage = Media.findOne({ - "metadata.variantId": variant._id - }); - return !!defaultImage; - } - }); - } - return defaultImage; - } -}); diff --git a/imports/plugins/core/checkout/server/methods/workflow.js b/imports/plugins/core/checkout/server/methods/workflow.js index bb6576e2dab..5b6222ec2b2 100644 --- a/imports/plugins/core/checkout/server/methods/workflow.js +++ b/imports/plugins/core/checkout/server/methods/workflow.js @@ -270,7 +270,9 @@ Meteor.methods({ "workflow/pushOrderWorkflow"(workflow, status, order) { check(workflow, String); check(status, String); - check(order, Object); // TODO: Validatate as Schemas.Order + check(order, Match.ObjectIncluding({ + _id: String + })); this.unblock(); const workflowStatus = `${workflow}/${status}`; @@ -305,7 +307,12 @@ Meteor.methods({ "workflow/pullOrderWorkflow"(workflow, status, order) { check(workflow, String); check(status, String); - check(order, Object); + check(order, Match.ObjectIncluding({ + _id: String, + workflow: Match.ObjectIncluding({ + status: String + }) + })); this.unblock(); const result = Orders.update({ @@ -333,7 +340,10 @@ Meteor.methods({ */ "workflow/pushItemWorkflow"(status, order, itemIds) { check(status, String); - check(order, Object); + check(order, Match.ObjectIncluding({ + _id: String, + items: [Object] + })); check(itemIds, Array); // We can't trust the order from the client (for several reasons) diff --git a/imports/plugins/core/collections/lib/validation.js b/imports/plugins/core/collections/lib/validation.js index 25469db1aa6..80d2dedab97 100644 --- a/imports/plugins/core/collections/lib/validation.js +++ b/imports/plugins/core/collections/lib/validation.js @@ -46,9 +46,9 @@ class Validation { const isValid = this.validationContext.validate(cleanedObject); // Avoiding the reactive-stuff built into simple-schema, grab invalid - // keys from the private var _invalidKeys, and create a new object with + // keys from the private var _validationErrors, and create a new object with // the validation error and message. - this.validationContext._invalidKeys + this.validationContext._validationErrors .forEach((validationError) => { messages[validationError.name] = { ...validationError, diff --git a/imports/plugins/core/dashboard/client/templates/import/import.js b/imports/plugins/core/dashboard/client/templates/import/import.js index 6c4b0873a85..cd440d88c56 100644 --- a/imports/plugins/core/dashboard/client/templates/import/import.js +++ b/imports/plugins/core/dashboard/client/templates/import/import.js @@ -1,7 +1,9 @@ import { Template } from "meteor/templating"; import { Meteor } from "meteor/meteor"; -import { Reaction } from "/client/api"; -import { Media, Products } from "/lib/collections"; +import { FileRecord } from "@reactioncommerce/file-collections"; +import { Logger, Reaction } from "/client/api"; +import { Products } from "/lib/collections"; +import { Media } from "/imports/plugins/core/files/client"; function uploadHandler(event) { const shopId = Reaction.getShopId(); @@ -23,15 +25,19 @@ function uploadHandler(event) { }); } if (product) { - const fileObj = new FS.File(files[i]); - fileObj.metadata = { + const fileRecord = FileRecord.fromFile(files[i]); + fileRecord.metadata = { ownerId: userId, productId: product._id, variantId: product.variants[0]._id, shopId, priority: Number(parts[1]) || 0 }; - Media.insert(fileObj); + fileRecord.upload() + .then(() => Media.insert(fileRecord)) + .catch((error) => { + Logger.error(error); + }); } } } diff --git a/imports/plugins/core/dashboard/client/templates/shop/settings/ShopBrandMediaManager.js b/imports/plugins/core/dashboard/client/templates/shop/settings/ShopBrandMediaManager.js new file mode 100644 index 00000000000..2c394aafdbf --- /dev/null +++ b/imports/plugins/core/dashboard/client/templates/shop/settings/ShopBrandMediaManager.js @@ -0,0 +1,38 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Components } from "@reactioncommerce/reaction-components"; +import ShopBrandImageOption from "./shopBrandImageOption"; + +class ShopBrandMediaManager extends Component { + static propTypes = { + brandMediaList: PropTypes.arrayOf(PropTypes.object), + metadata: PropTypes.object, + selectedMediaId: PropTypes.string + }; + + renderBrandImages() { + const { brandMediaList, selectedMediaId } = this.props; + + return (brandMediaList || []).map((media) => ( + + )); + } + + render() { + const { metadata } = this.props; + + return ( +
+ {/* DragDropProvider is needed to avoid errors but we don't currently support dragging */} + +
+ {this.renderBrandImages()} +
+
+ +
+ ); + } +} + +export default ShopBrandMediaManager; diff --git a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html index a3563242449..60056897fe8 100644 --- a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html +++ b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.html @@ -1,16 +1,4 @@ - - diff --git a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.js b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.js index 254c0b41217..2d27768a858 100644 --- a/imports/plugins/core/dashboard/client/templates/shop/settings/settings.js +++ b/imports/plugins/core/dashboard/client/templates/shop/settings/settings.js @@ -3,60 +3,10 @@ import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; import { AutoForm } from "meteor/aldeed:autoform"; import { Reaction, i18next } from "/client/api"; -import { Media, Packages, Shops } from "/lib/collections"; +import { Packages, Shops } from "/lib/collections"; +import { Media } from "/imports/plugins/core/files/client"; +import ShopBrandMediaManager from "./ShopBrandMediaManager"; -Template.shopBrandImageOption.helpers({ - cardProps(data) { - const props = { - controls: [] - }; - - // Add the enable / disable toggle button - props.controls.push({ - icon: "square-o", - onIcon: "check-square-o", - toggle: true, - toggleOn: data.selected, - onClick() { - const asset = { - mediaId: data.option._id, - type: "navbarBrandImage" - }; - - Meteor.call("shop/updateBrandAssets", asset, (error, result) => { - if (error) { - // Display Error - return Alerts.toast(i18next.t("shopSettings.shopBrandAssetsFailed"), "error"); - } - - if (result === 1) { - Alerts.toast(i18next.t("shopSettings.shopBrandAssetsSaved"), "success"); - } - }); - } - }); - - // Show the delete button for brand assets that are not enabled. - // This will prevent users from deleting assets that are being used at the moment. - if (!data.selected) { - props.controls.push({ - icon: "trash-o", - onClick() { - Alerts.alert({ - title: "Remove this brand image?", - type: "warning", - showCancelButton: true, - confirmButtonText: "Remove" - }, () => { - Media.findOne(data.option._id).remove(); - }); - } - }); - } - - return props; - } -}); /** * shopSettings helpers @@ -75,14 +25,9 @@ Template.shopSettings.helpers({ } return ""; }, - brandImageSelectProps() { + ShopBrandMediaManager() { const shopId = Reaction.getShopId(); - const media = Media.find({ - "metadata.shopId": shopId, - "metadata.type": "brandAsset" - }); - const shop = Shops.findOne({ "_id": shopId, "brandAssets.type": "navbarBrandImage" @@ -93,53 +38,21 @@ Template.shopSettings.helpers({ selectedMediaId = shop.brandAssets[0].mediaId; } - return { - type: "radio", - options: media, - key: "_id", - optionTemplate: "shopBrandImageOption", - selected: selectedMediaId, - classNames: { - itemList: { half: true }, - input: { hidden: true } - }, - onSelect(value) { - const asset = { - mediaId: value, - type: "navbarBrandImage" - }; - - Meteor.call("shop/updateBrandAssets", asset, (error, result) => { - if (error) { - // Display Error - return Alerts.toast("Couldn't update brand asset.", "error"); - } - - if (result === 1) { - Alerts.toast("Updated brand asset", "success"); - } - }); - } - }; - }, - - handleFileUpload() { const userId = Meteor.userId(); - const shopId = Reaction.getShopId(); + const metadata = { type: "brandAsset", ownerId: userId, shopId }; - return (files) => { - for (const file of files) { - file.metadata = { - type: "brandAsset", - ownerId: userId, - shopId - }; + const brandMediaList = Media.findLocal({ + "metadata.shopId": Reaction.getShopId(), + "metadata.type": "brandAsset" + }); - Media.insert(file); - } + return { + component: ShopBrandMediaManager, + brandMediaList, + metadata, + selectedMediaId }; }, - shop() { return Shops.findOne({ _id: Reaction.getShopId() diff --git a/imports/plugins/core/dashboard/client/templates/shop/settings/shopBrandImageOption.js b/imports/plugins/core/dashboard/client/templates/shop/settings/shopBrandImageOption.js new file mode 100644 index 00000000000..cedf6cf6481 --- /dev/null +++ b/imports/plugins/core/dashboard/client/templates/shop/settings/shopBrandImageOption.js @@ -0,0 +1,60 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Meteor } from "meteor/meteor"; +import { Components, registerComponent } from "@reactioncommerce/reaction-components"; +import { Media } from "/imports/plugins/core/files/client"; +import { i18next, Logger } from "/client/api"; + +class ShopBrandImageOption extends Component { + static propTypes = { + isSelected: PropTypes.bool, + media: PropTypes.object.isRequired + }; + + handleClick = () => { + const { isSelected, media } = this.props; + + if (isSelected) return; + + const asset = { mediaId: media._id, type: "navbarBrandImage" }; + + Meteor.call("shop/updateBrandAssets", asset, (error, result) => { + if (error || result !== 1) { + return Alerts.toast(i18next.t("shopSettings.shopBrandAssetsFailed"), "error"); + } + + Alerts.toast(i18next.t("shopSettings.shopBrandAssetsSaved"), "success"); + }); + }; + + handleRemoveClick = () => { + const { media } = this.props; + + Alerts.alert({ + title: "Remove this brand image?", + type: "warning", + showCancelButton: true, + confirmButtonText: "Remove" + }, (shouldRemove) => { + if (shouldRemove) Media.remove(media._id).catch((error) => { Logger.error(error); }); + }); + }; + + render() { + const { media } = this.props; + + return ( + + ); + } +} + +registerComponent("ShopBrandImageOption", ShopBrandImageOption); + +export default ShopBrandImageOption; diff --git a/imports/plugins/core/discounts/client/components/list.js b/imports/plugins/core/discounts/client/components/list.js index 654f84407ad..6fca17795e3 100644 --- a/imports/plugins/core/discounts/client/components/list.js +++ b/imports/plugins/core/discounts/client/components/list.js @@ -74,14 +74,13 @@ function composer(props, onData) { }); const listItems = []; - for (const billing of currentCart.billing) { - if (billing.paymentMethod && billing.paymentMethod.processor === "code") { - listItems.push({ - id: billing._id, - code: billing.paymentMethod.code, - discount: billing.paymentMethod.amount - }); - } + const listItem = currentCart.billing.find((element) => element.paymentMethod && element.paymentMethod.processor === "code"); + if (listItem) { + listItems.push({ + id: listItem._id, + code: listItem.paymentMethod.code, + discount: listItem.paymentMethod.amount + }); } onData(null, { diff --git a/imports/plugins/core/discounts/lib/collections/schemas/config.js b/imports/plugins/core/discounts/lib/collections/schemas/config.js index ca7db42f77e..b4fbca2e03f 100644 --- a/imports/plugins/core/discounts/lib/collections/schemas/config.js +++ b/imports/plugins/core/discounts/lib/collections/schemas/config.js @@ -1,29 +1,36 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { registerSchema } from "@reactioncommerce/reaction-collections"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { Discounts } from "./discounts"; - /** * DiscountsPackageConfig Schema */ -export const DiscountsPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.rates": { - type: Object, - optional: true - }, - "settings.rates.enabled": { - type: Boolean, - optional: true, - defaultValue: false - }, - "settings.rates.discounts": { - type: [Discounts], - optional: true - } +export const DiscountsPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.rates": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.rates.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.rates.discounts": { + type: Array, + optional: true + }, + "settings.rates.discounts.$": { + type: Discounts } -]); +}); registerSchema("DiscountsPackageConfig", DiscountsPackageConfig); diff --git a/imports/plugins/core/discounts/lib/collections/schemas/discounts.js b/imports/plugins/core/discounts/lib/collections/schemas/discounts.js index 3d8985090e5..82aba5e29f9 100644 --- a/imports/plugins/core/discounts/lib/collections/schemas/discounts.js +++ b/imports/plugins/core/discounts/lib/collections/schemas/discounts.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { shopIdAutoValue } from "/lib/collections/schemas/helpers"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -22,7 +24,7 @@ export const Transactions = new SimpleSchema({ type: Date, optional: true } -}); +}, { check, tracker: Tracker }); registerSchema("Transactions", Transactions); @@ -58,13 +60,17 @@ export const Discounts = new SimpleSchema({ optional: true }, "transactions": { - type: [Transactions], + type: Array, optional: true }, + "transactions.$": { + type: Transactions + }, "calculation": { type: Object, optional: true, - label: "Calculation" + label: "Calculation", + defaultValue: {} }, "calculation.method": { type: String, @@ -75,21 +81,21 @@ export const Discounts = new SimpleSchema({ "conditions": { type: Object, optional: true, - label: "Conditions" + label: "Conditions", + defaultValue: {} }, "conditions.order": { - type: Object + type: Object, + defaultValue: {} }, "conditions.order.min": { type: Number, label: "Mininum", - decimal: true, defaultValue: 0.00 }, "conditions.order.max": { type: Number, label: "Maximum", - decimal: true, optional: true }, "conditions.order.startDate": { @@ -109,25 +115,45 @@ export const Discounts = new SimpleSchema({ optional: true }, "conditions.audience": { - type: [String], + type: Array, + optional: true, + label: "Audience" + }, + "conditions.audience.$": { + type: String, optional: true, label: "Audience" }, "conditions.permissions": { - type: [String], + type: Array, optional: true, label: "Permissions" }, + "conditions.permissions.$": { + type: String, + optional: true, + label: "Permission" + }, "conditions.products": { - type: [String], + type: Array, optional: true, label: "Products" }, + "conditions.products.$": { + type: String, + optional: true, + label: "Product" + }, "conditions.tags": { - type: [String], + type: Array, optional: true, label: "Tags" + }, + "conditions.tags.$": { + type: String, + optional: true, + label: "Tag" } -}); +}, { check, tracker: Tracker }); registerSchema("Discounts", Discounts); diff --git a/imports/plugins/core/discounts/server/api/import.js b/imports/plugins/core/discounts/server/api/import.js index f0bf04e19e2..394f6002da1 100644 --- a/imports/plugins/core/discounts/server/api/import.js +++ b/imports/plugins/core/discounts/server/api/import.js @@ -1,9 +1,9 @@ import { Reaction } from "/server/api"; -import { Import } from "/server/api/core/import"; +import { Importer } from "/server/api/core/importer"; import * as Collections from "../../lib/collections"; // plugin Import helpers -const DiscountImport = Import; +const DiscountImport = Importer; // Import helper to store a discountRate in the import buffer. DiscountImport.discountRate = function (key, discountRate) { @@ -14,7 +14,7 @@ DiscountImport.discountRate = function (key, discountRate) { DiscountImport.indication("discount", Collections.Discounts, 0.5); // should assign to global -Object.assign(Reaction.Import, DiscountImport); +Object.assign(Reaction.Importer, DiscountImport); -// exports Reaction.Import with new discount helper +// exports Reaction.Importer with new discount helper export default Reaction; diff --git a/imports/plugins/core/discounts/server/methods/methods.js b/imports/plugins/core/discounts/server/methods/methods.js index 236d3297d5d..bbd952ab9f4 100644 --- a/imports/plugins/core/discounts/server/methods/methods.js +++ b/imports/plugins/core/discounts/server/methods/methods.js @@ -86,7 +86,7 @@ export const methods = { * @return {Object} returns discount object */ "discounts/calculate"(cart) { - check(cart, Object); // Reaction.Schemas.Cart + Reaction.Schemas.Cart.validate(cart); let currentDiscount = 0; // what's going on here? diff --git a/imports/plugins/core/files/client/index.js b/imports/plugins/core/files/client/index.js new file mode 100644 index 00000000000..d8a72c1f31c --- /dev/null +++ b/imports/plugins/core/files/client/index.js @@ -0,0 +1,13 @@ +import { Meteor } from "meteor/meteor"; +import { MeteorFileCollection, FileRecord } from "@reactioncommerce/file-collections"; +import { MediaRecords } from "/lib/collections"; + +FileRecord.downloadEndpointPrefix = "/assets/files"; +FileRecord.uploadEndpoint = "/assets/uploads"; +FileRecord.absoluteUrlPrefix = Meteor.absoluteUrl(); + +export const Media = new MeteorFileCollection("Media", { + // The backing Meteor Mongo collection, which you must make sure is published to clients as necessary + collection: MediaRecords, + DDP: Meteor +}); diff --git a/imports/plugins/core/files/server/fileCollections.js b/imports/plugins/core/files/server/fileCollections.js new file mode 100644 index 00000000000..6f5d62d7731 --- /dev/null +++ b/imports/plugins/core/files/server/fileCollections.js @@ -0,0 +1,188 @@ +import { Meteor } from "meteor/meteor"; +import { MongoInternals } from "meteor/mongo"; +import { WebApp } from "meteor/webapp"; +import { check } from "meteor/check"; +import { Security } from "meteor/ongoworks:security"; +import fetch from "node-fetch"; +import { + FileDownloadManager, + FileRecord, + MeteorFileCollection, + RemoteUrlWorker, + TempFileStore, + TempFileStoreWorker +} from "@reactioncommerce/file-collections"; +import GridFSStore from "@reactioncommerce/file-collections-sa-gridfs"; +import { Logger } from "/server/api"; +import { MediaRecords } from "/lib/collections"; + +// lazy loading sharp package +let sharp; +async function lazyLoadSharp() { + if (sharp) return; + sharp = await import("sharp"); +} + +FileRecord.downloadEndpointPrefix = "/assets/files"; +FileRecord.absoluteUrlPrefix = Meteor.absoluteUrl(); + +// 1024*1024*2 is the GridFSStore default chunk size, and 256k is default GridFS chunk size, but performs terribly +const gridFSStoresChunkSize = 1 * 1024 * 1024; +const mongodb = MongoInternals.NpmModules.mongodb.module; +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +/** + * @name imgTransforms + * @constant {Array} + * @property {string} name - transform name that will be used as GridFS name + * @property {object|undefined} transform - object with image transform settings + * @property {number} size - transform size, only one number needed for both width & height + * @property {string} mod - transform modifier function call, + * for example the `large` & `medium` image transforms want to preserve + * the image's aspect ratio and resize based on the larger width or height + * so we use the `max` Sharp modifier function. + * Check out the {@link http://sharp.pixelplumbing.com/en/stable/|Sharp Docs} for more helper functions. + * {@link http://sharp.pixelplumbing.com/en/stable/api-resize/#max|Sharp max()} + * {@link http://sharp.pixelplumbing.com/en/stable/api-resize/#crop|Sharp crop()} + * @property {string} format - output image format + * @summary Defines all image transforms + * Image files are resized to 4 different sizes: + * 1. `large` - 1000px by 1000px - preserves aspect ratio + * 2. `medium` - 600px by 600px - preserves aspect ratio + * 3. `small` - 235px by 235px - crops to square - creates png version + * 4. `thumbnail` - 100px by 100px - crops to square - creates png version + */ +const imgTransforms = [ + { name: "image", transform: { size: 1600, mod: "max", format: "jpg", type: "image/jpeg" } }, + { name: "large", transform: { size: 1000, mod: "max", format: "jpg", type: "image/jpeg" } }, + { name: "medium", transform: { size: 600, mod: "max", format: "jpg", type: "image/jpeg" } }, + { name: "small", transform: { size: 235, mod: "crop", format: "png", type: "image/png" } }, + { name: "thumbnail", transform: { size: 100, mod: "crop", format: "png", type: "image/png" } } +]; + +/** + * @function buildGFS + * @param {object} imgTransform + * @summary buildGFS returns a fresh GridFSStore instance from provided image transform settings. + */ +const buildGFS = ({ name, transform }) => ( + new GridFSStore({ + chunkSize: gridFSStoresChunkSize, + collectionPrefix: "cfs_gridfs.", + db, + mongodb, + name, + async transformWrite(fileRecord) { + if (!transform) return; + + try { + await lazyLoadSharp(); + } catch (error) { + Logger.warn("Problem lazy loading Sharp lib in image transformWrite", error.message); + } + + if (!sharp) { + Logger.warn("In transformWrite, sharp does not seem to be available"); + return; + } + + const { size, mod, format, type } = transform; + // Need to update the content type and extension of the file info, too. + // The new size gets set correctly automatically by FileCollections package. + fileRecord.type(type, { store: name }); + fileRecord.extension(format, { store: name }); + + // resizing image, adding mod, setting output format + return sharp().resize(size, size)[mod]().toFormat(format); + } + }) +); + +/** + * @name stores + * @constant {Array} + * @summary Defines an array of GridFSStore by mapping the imgTransform settings over the buildGFS function + */ +const stores = imgTransforms.map(buildGFS); + +/** + * @name tempStore + * @type TempFileStore + * @summary Defines the temporary store where chunked uploads from browsers go + * initially, until the chunks are eventually combined into one complete file + * which the worker will then store to the permanant stores. + * @see https://github.com/reactioncommerce/reaction-file-collections + */ +const tempStore = new TempFileStore({ + shouldAllowRequest(req) { + const { type } = req.uploadMetadata; + if (typeof type !== "string" || !type.startsWith("image/")) { + Logger.info(`shouldAllowRequest received request to upload file of type "${type}" and denied it`); + return false; + } + return true; + } +}); +WebApp.connectHandlers.use("/assets/uploads", (req, res) => { + req.baseUrl = "/assets/uploads"; // tus relies on this being set, which is an Express thing + tempStore.connectHandler(req, res); +}); + +/** + * @name Media + * @type MeteorFileCollection + * @summary Defines the Media FileCollection + * To learn how to further manipulate images with Sharp, refer to + * {@link http://sharp.pixelplumbing.com/en/stable/|Sharp Docs} + * @see https://github.com/reactioncommerce/reaction-file-collections + */ +export const Media = new MeteorFileCollection("Media", { + allowInsert: (userId, doc) => Security.can(userId).insert(doc).for(MediaRecords).check(), + allowUpdate: (userId, id, modifier) => Security.can(userId).update(id, modifier).for(MediaRecords).check(), + allowRemove: (userId, id) => Security.can(userId).remove(id).for(MediaRecords).check(), + check, + collection: MediaRecords, + DDP: Meteor, + allowGet: () => true, // add more security here if the files should not be public + stores, + tempStore +}); + +// For backward-compatibility with code relying on how CFS did it, we'll put a +// reference to the backing MongoDB collection on Media.files property as well. +Media.files = MediaRecords; + +/** + * @name downloadManager + * @type FileDownloadManager + * @summary Set up a URL for downloading the files + * @see https://github.com/reactioncommerce/reaction-file-collections + */ +const downloadManager = new FileDownloadManager({ + collections: [Media], + headers: { + get: { + "Cache-Control": "public, max-age=31536000" + } + } +}); +WebApp.connectHandlers.use("/assets/files", downloadManager.connectHandler); + +/** + * @name remoteUrlWorker + * @type RemoteUrlWorker + * @summary Start a worker to watch for inserted remote URLs and stream them to all stores + * @see https://github.com/reactioncommerce/reaction-file-collections + */ +const remoteUrlWorker = new RemoteUrlWorker({ fetch, fileCollections: [Media] }); +remoteUrlWorker.start(); + +/** + * @name fileWorker + * @type TempFileStoreWorker + * @summary Start a worker to watch for finished uploads, store them permanantly, + * and then remove the temporary file + * @see https://github.com/reactioncommerce/reaction-file-collections + */ +const fileWorker = new TempFileStoreWorker({ fileCollections: [Media] }); +fileWorker.start(); diff --git a/imports/plugins/core/files/server/index.js b/imports/plugins/core/files/server/index.js new file mode 100644 index 00000000000..d3983812248 --- /dev/null +++ b/imports/plugins/core/files/server/index.js @@ -0,0 +1,4 @@ +import { Media } from "./fileCollections"; +import "./methods"; + +export { Media }; diff --git a/imports/plugins/core/files/server/methods.js b/imports/plugins/core/files/server/methods.js new file mode 100644 index 00000000000..771c5a3bb2c --- /dev/null +++ b/imports/plugins/core/files/server/methods.js @@ -0,0 +1,50 @@ +import { Meteor } from "meteor/meteor"; +import { check } from "meteor/check"; +import { Reaction } from "/server/api"; +import { MediaRecords } from "/lib/collections"; + +Meteor.methods({ + /** + * updateMediaPriorities + * @summary sorting media by array indexes + * @type {Method} + * @param {String[]} sortedMediaIDs + * @return {Boolean} true + */ + "media/updatePriorities"(sortedMediaIDs) { + check(sortedMediaIDs, [String]); + + if (!Reaction.hasPermission("createProduct")) { + throw new Meteor.Error("access-denied", "Access Denied"); + } + + // Check to be sure product linked with each media belongs to the current user's current shop + const shopId = Reaction.getShopId(); + + const sortedMediaRecords = MediaRecords.find({ + _id: { $in: sortedMediaIDs } + }).fetch(); + + sortedMediaRecords.forEach((mediaRecord) => { + if (!mediaRecord.metadata || mediaRecord.metadata.shopId !== shopId) { + throw new Meteor.Error("access-denied", `Access Denied. No access to shop ${mediaRecord.metadata.shopId}`); + } + }); + + if (sortedMediaRecords.length !== sortedMediaIDs.length) { + throw new Meteor.Error("not-found", "At least one ID in sortedMediaIDs does not exist"); + } + + sortedMediaIDs.forEach((_id, index) => { + MediaRecords.update({ + _id + }, { + $set: { + "metadata.priority": index + } + }); + }); + + return true; + } +}); diff --git a/imports/plugins/core/i18n/client/components/localizationSettings.js b/imports/plugins/core/i18n/client/components/localizationSettings.js index dea84307091..a2764dacf0e 100644 --- a/imports/plugins/core/i18n/client/components/localizationSettings.js +++ b/imports/plugins/core/i18n/client/components/localizationSettings.js @@ -23,6 +23,54 @@ class LocalizationSettings extends Component { uomOptions: PropTypes.array } + constructor(props) { + super(props); + + this.state = { + currencies: props.currencies, + languages: props.languages + }; + } + + componentWillReceiveProps(nextProps) { + this.setState({ + currencies: nextProps.currencies, + languages: nextProps.languages + }); + } + + handleUpdateCurrencyConfiguration = (event, isChecked, name) => { + const currencyIndex = this.state.currencies.findIndex((currency) => currency.name === name); + + this.setState((state) => { + const newStateCurrencies = state.currencies; + newStateCurrencies[currencyIndex].enabled = isChecked; + return { currencies: newStateCurrencies }; + }, () => { + // Delaying to allow animation before sending data to server + // If animation is not delayed, it twitches when actual update happens + setTimeout(() => { + this.props.onUpdateCurrencyConfiguration(event, isChecked, name); + }, 200); + }); + } + + handleUpdateLangaugeConfiguration = (event, isChecked, name) => { + const languageIndex = this.state.languages.findIndex((language) => language.value === name); + + this.setState((state) => { + const newStateLanguages = state.languages; + newStateLanguages[languageIndex].enabled = isChecked; + return { languages: newStateLanguages }; + }, () => { + // Delaying to allow animation before sending data to server + // If animation is not delayed, it twitches when actual update happens + setTimeout(() => { + this.props.onUpdateLanguageConfiguration(event, isChecked, name); + }, 200); + }); + } + renderCurrencies() { return this.props.currencies.map((currency, key) => ( )); } renderLanguages() { - return this.props.languages.map((language, key) => ( + return this.state.languages.map((language, key) => ( )); } diff --git a/imports/plugins/core/layout/lib/layouts.js b/imports/plugins/core/layout/lib/layouts.js index f3446789ec1..3901e7a22ca 100644 --- a/imports/plugins/core/layout/lib/layouts.js +++ b/imports/plugins/core/layout/lib/layouts.js @@ -1,8 +1,8 @@ -import { Import } from "/server/api/core/import"; +import { Importer } from "/server/api/core/importer"; import { Shops } from "/lib/collections"; export function registerLayout(layout) { Shops.find().forEach((shop) => { - Import.layout(layout, shop._id); + Importer.layout(layout, shop._id); }); } diff --git a/imports/plugins/core/orders/client/components/invoice.js b/imports/plugins/core/orders/client/components/invoice.js index 150e80f47aa..e184bcccd7d 100644 --- a/imports/plugins/core/orders/client/components/invoice.js +++ b/imports/plugins/core/orders/client/components/invoice.js @@ -29,6 +29,7 @@ class Invoice extends Component { static propTypes = { canMakeAdjustments: PropTypes.bool, discounts: PropTypes.bool, + displayMedia: PropTypes.func, hasRefundingEnabled: PropTypes.bool, invoice: PropTypes.object, isFetching: PropTypes.bool, diff --git a/imports/plugins/core/orders/client/components/lineItems.js b/imports/plugins/core/orders/client/components/lineItems.js index ab728aaf9a5..8805165400d 100644 --- a/imports/plugins/core/orders/client/components/lineItems.js +++ b/imports/plugins/core/orders/client/components/lineItems.js @@ -59,18 +59,13 @@ class LineItems extends Component { displayMedia(uniqueItem) { const { displayMedia } = this.props; - if (displayMedia(uniqueItem)) { - return ( - - ); - } return ( - + ); } diff --git a/imports/plugins/core/orders/client/components/orderDashboard.js b/imports/plugins/core/orders/client/components/orderDashboard.js index c851cc296c9..7802da6926a 100644 --- a/imports/plugins/core/orders/client/components/orderDashboard.js +++ b/imports/plugins/core/orders/client/components/orderDashboard.js @@ -8,7 +8,6 @@ import OrderSearch from "./orderSearch"; class OrderDashboard extends Component { static propTypes = { clearFilter: PropTypes.func, - displayMedia: PropTypes.func, filterDates: PropTypes.func, filterShippingStatus: PropTypes.func, filterWorkflowStatus: PropTypes.func, diff --git a/imports/plugins/core/orders/client/components/productImage.js b/imports/plugins/core/orders/client/components/productImage.js index 40b727b4e2e..7c5e3da8be8 100644 --- a/imports/plugins/core/orders/client/components/productImage.js +++ b/imports/plugins/core/orders/client/components/productImage.js @@ -3,64 +3,69 @@ import PropTypes from "prop-types"; import { registerComponent } from "@reactioncommerce/reaction-components"; import { Badge } from "@reactioncommerce/reaction-ui"; +const MEDIA_PLACEHOLDER = "/resources/placeholder.gif"; class ProductImage extends Component { static propTypes = { badge: PropTypes.bool, - displayMedia: PropTypes.func, - item: PropTypes.object, + displayMedia: PropTypes.func.isRequired, + item: PropTypes.shape({ + quantity: PropTypes.number, + title: PropTypes.string.isRequired + }).isRequired, + mode: PropTypes.oneOf(["span", "img"]), size: PropTypes.string } + static defaultProps = { + badge: false, + mode: "img", + size: "large" + }; + renderBadge() { const { badge, item } = this.props; - if (badge === true) { - return ( - - ); - } - return false; + if (!badge) return false; + + return ( + + ); } - renderMedia() { - const { displayMedia, item, size } = this.props; - let mediaUrl; + render() { + const { displayMedia, item, mode, size } = this.props; - if (displayMedia(item)) { - const rawMediaUrl = displayMedia(item).url(); - mediaUrl = rawMediaUrl; + const fileRecord = displayMedia(item); - if (size) { - mediaUrl = `${rawMediaUrl}&store=${size}`; - } + let mediaUrl; + if (fileRecord) { + mediaUrl = fileRecord.url({ store: size }); } else { - mediaUrl = "/resources/placeholder.gif"; + mediaUrl = MEDIA_PLACEHOLDER; + } + + if (mode === "span") { + return ( + + ); } return (
{item.title} {this.renderBadge()}
); } - - render() { - return ( -
- {this.renderMedia()} -
- ); - } } registerComponent("ProductImage", ProductImage); diff --git a/imports/plugins/core/orders/client/containers/invoiceContainer.js b/imports/plugins/core/orders/client/containers/invoiceContainer.js index 6f5f73b2e55..f28feda5024 100644 --- a/imports/plugins/core/orders/client/containers/invoiceContainer.js +++ b/imports/plugins/core/orders/client/containers/invoiceContainer.js @@ -4,7 +4,8 @@ import accounting from "accounting-js"; import _ from "lodash"; import { Meteor } from "meteor/meteor"; import { i18next, Logger, Reaction, formatPriceString } from "/client/api"; -import { Media, Packages } from "/lib/collections"; +import { Packages } from "/lib/collections"; +import { getPrimaryMediaForOrderItem } from "/lib/api"; import { composeWithTracker, registerComponent } from "@reactioncommerce/reaction-components"; import Invoice from "../components/invoice.js"; import { getOrderRiskStatus, getOrderRiskBadge, getBillingInfo } from "../helpers"; @@ -61,30 +62,6 @@ class InvoiceContainer extends Component { }); }; - handleDisplayMedia = (item) => { - const variantId = item.variants._id; - const { productId } = item; - - const variantImage = Media.findOne({ - "metadata.variantId": variantId, - "metadata.productId": productId - }); - - if (variantImage) { - return variantImage; - } - - const defaultImage = Media.findOne({ - "metadata.productId": productId, - "metadata.priority": 0 - }); - - if (defaultImage) { - return defaultImage; - } - return false; - } - handleItemSelect = (lineItem) => { let { selectedItems, editedItems } = this.state; @@ -196,35 +173,6 @@ class InvoiceContainer extends Component { }); } - /** - * Media - find media based on a product/variant - * @param {Object} item object containing a product and variant id - * @return {Object|false} An object contianing the media or false - */ - handleDisplayMedia = (item) => { - const variantId = item.variants._id; - const { productId } = item; - - const variantImage = Media.findOne({ - "metadata.variantId": variantId, - "metadata.productId": productId - }); - - if (variantImage) { - return variantImage; - } - - const defaultImage = Media.findOne({ - "metadata.productId": productId, - "metadata.priority": 0 - }); - - if (defaultImage) { - return defaultImage; - } - return false; - } - getRefundedItemsInfo = () => { const { editedItems } = this.state; return { @@ -483,7 +431,7 @@ class InvoiceContainer extends Component { togglePopOver={this.togglePopOver} handleInputChange={this.handleInputChange} handleItemSelect={this.handleItemSelect} - displayMedia={this.handleDisplayMedia} + displayMedia={getPrimaryMediaForOrderItem} toggleUpdating={this.toggleUpdating} handleRefundItems={this.handleRefundItems} getRefundedItemsInfo={this.getRefundedItemsInfo} diff --git a/imports/plugins/core/orders/client/containers/orderTableContainer.js b/imports/plugins/core/orders/client/containers/orderTableContainer.js index ae6803678b8..889954dc622 100644 --- a/imports/plugins/core/orders/client/containers/orderTableContainer.js +++ b/imports/plugins/core/orders/client/containers/orderTableContainer.js @@ -2,7 +2,7 @@ import React, { Component } from "react"; import { compose } from "recompose"; import { Meteor } from "meteor/meteor"; import { Reaction, i18next } from "/client/api"; -import { Media } from "/lib/collections"; +import { getPrimaryMediaForOrderItem } from "/lib/api"; import { registerComponent } from "@reactioncommerce/reaction-components"; import { getShippingInfo } from "../helpers"; import { @@ -145,35 +145,6 @@ const wrapComponent = (Comp) => ( Reaction.setUserPreferences(PACKAGE_NAME, ORDER_LIST_SELECTED_ORDER_PREFERENCE_NAME, order._id); } - /** - * Media - find media based on a product/variant - * @param {Object} item object containing a product and variant id - * @return {Object|false} An object contianing the media or false - */ - handleDisplayMedia = (item) => { - const variantId = item.variants._id; - const { productId } = item; - - const variantImage = Media.findOne({ - "metadata.variantId": variantId, - "metadata.productId": productId - }); - - if (variantImage) { - return variantImage; - } - - const defaultImage = Media.findOne({ - "metadata.productId": productId, - "metadata.priority": 0 - }); - - if (defaultImage) { - return defaultImage; - } - return false; - } - /** * updateBulkStatusHelper * @@ -639,6 +610,17 @@ const wrapComponent = (Comp) => ( } } + /** + * orderCreditMethod: Finds the credit record in order.billing for the active shop + * @param order: The order where to find the billing record in. + * @return: The billing record with paymentMethod.method === credit of currently active shop + */ + orderCreditMethod(order) { + const creditBillingRecords = order.billing.filter((value) => value.paymentMethod.method === "credit"); + const billingRecord = creditBillingRecords.find((billing) => billing.shopId === Reaction.getShopId()); + return billingRecord; + } + handleBulkPaymentCapture = (selectedOrdersIds, orders) => { this.setState({ isLoading: { @@ -648,10 +630,31 @@ const wrapComponent = (Comp) => ( const selectedOrders = orders.filter((order) => selectedOrdersIds.includes(order._id)); let orderCount = 0; + const done = () => { + orderCount += 1; + if (orderCount === selectedOrders.length) { + this.setState({ + isLoading: { + capturePayment: false + } + }); + Alerts.alert({ + text: i18next.t("order.paymentCaptureSuccess"), + type: "success", + allowOutsideClick: false + }); + } + }; // TODO: send these orders in batch as an array. This would entail re-writing the // "orders/approvePayment" method to receive an array of orders as a param. selectedOrders.forEach((order) => { + // Only capture orders which are not captured yet (but possibly are already approved) + const billingRecord = this.orderCreditMethod(order); + if (billingRecord.paymentMethod.mode === "capture" && billingRecord.paymentMethod.status === "completed") { + done(); + return; + } Meteor.call("orders/approvePayment", order, (approvePaymentError) => { if (approvePaymentError) { this.setState({ @@ -672,20 +675,7 @@ const wrapComponent = (Comp) => ( }); Alerts.toast(`An error occured while capturing the payment: ${capturePaymentError}`, "error"); } - - orderCount += 1; - if (orderCount === selectedOrders.length) { - this.setState({ - isLoading: { - capturePayment: false - } - }); - Alerts.alert({ - text: i18next.t("order.paymentCaptureSuccess"), - type: "success", - allowOutsideClick: false - }); - } + done(); }); } }); @@ -698,7 +688,7 @@ const wrapComponent = (Comp) => ( {...this.props} handleSelect={this.handleSelect} handleClick={this.handleClick} - displayMedia={this.handleDisplayMedia} + displayMedia={getPrimaryMediaForOrderItem} selectedItems={this.state.selectedItems} selectAllOrders={this.selectAllOrders} multipleSelect={this.state.multipleSelect} diff --git a/imports/plugins/core/orders/client/index.js b/imports/plugins/core/orders/client/index.js index 5ed49d3476a..d4016497231 100644 --- a/imports/plugins/core/orders/client/index.js +++ b/imports/plugins/core/orders/client/index.js @@ -1,7 +1,5 @@ import "./helpers"; -import "./templates/list/items.html"; -import "./templates/list/items.js"; import "./templates/list/pdf.html"; import "./templates/list/pdf.js"; import "./templates/list/summary.html"; diff --git a/imports/plugins/core/orders/client/templates/list/items.html b/imports/plugins/core/orders/client/templates/list/items.html deleted file mode 100644 index d605f4e2dda..00000000000 --- a/imports/plugins/core/orders/client/templates/list/items.html +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/imports/plugins/core/orders/client/templates/list/items.js b/imports/plugins/core/orders/client/templates/list/items.js deleted file mode 100644 index 1c3367a398f..00000000000 --- a/imports/plugins/core/orders/client/templates/list/items.js +++ /dev/null @@ -1,65 +0,0 @@ -import { Template } from "meteor/templating"; -import { Meteor } from "meteor/meteor"; -import { Media } from "/lib/collections"; - -/** - * ordersListItems helpers - * - */ -Template.ordersListItems.helpers({ - media() { - const cartImagesSub = Meteor.subscribe("CartItemImage", this); - if (cartImagesSub.ready()) { - const variantImage = Media.findOne({ - "metadata.productId": this.productId, - "metadata.variantId": this.variants._id - }); - // variant image - if (variantImage) { - return variantImage; - } - // find a default image - const productImage = Media.findOne({ - "metadata.productId": this.productId - }); - if (productImage) { - return productImage; - } - return false; - } - return false; - }, - - items() { - const { order } = Template.instance().data; - const combinedItems = []; - - - if (order) { - // Lopp through all items in the order. The items are split into indivital items - for (const orderItem of order.items) { - // Find an exising item in the combinedItems array - const foundItem = combinedItems.find((combinedItem) => { - // If and item variant exists, then we return true - if (combinedItem.variants) { - return combinedItem.variants._id === orderItem.variants._id; - } - - return false; - }); - - // Increment the quantity count for the duplicate product variants - if (foundItem) { - foundItem.quantity += 1; - } else { - // Otherwise push the unique item into the combinedItems array - combinedItems.push(orderItem); - } - } - - return combinedItems; - } - - return false; - } -}); diff --git a/imports/plugins/core/orders/client/templates/list/pdf.js b/imports/plugins/core/orders/client/templates/list/pdf.js index d14d7a71dde..38e5f747668 100644 --- a/imports/plugins/core/orders/client/templates/list/pdf.js +++ b/imports/plugins/core/orders/client/templates/list/pdf.js @@ -28,7 +28,6 @@ Template.completedPDFLayout.onCreated(async function () { const order = Orders.findOne({ _id: currentRoute.params.id }); - this.state.set({ order }); @@ -42,7 +41,7 @@ Template.completedPDFLayout.helpers({ }, billing() { const order = Template.instance().state.get("order"); - if (order && order.length) { + if (order && order.billing && order.billing.length) { return order.billing[0]; } diff --git a/imports/plugins/core/revisions/client/components/publishControls.js b/imports/plugins/core/revisions/client/components/publishControls.js index 057602a039c..8381547b691 100644 --- a/imports/plugins/core/revisions/client/components/publishControls.js +++ b/imports/plugins/core/revisions/client/components/publishControls.js @@ -214,11 +214,13 @@ class PublishControls extends Component { buttonProps.i18nKeyLabel = "toolbar.publishAll"; } + const isDisabled = Array.isArray(this.props.documentIds) && this.props.documentIds.length === 0; + return (
diff --git a/imports/plugins/core/ui/client/components/calendarPicker/calendarPicker.js b/imports/plugins/core/ui/client/components/calendarPicker/calendarPicker.js index c7e413549df..1e3b29d46c1 100644 --- a/imports/plugins/core/ui/client/components/calendarPicker/calendarPicker.js +++ b/imports/plugins/core/ui/client/components/calendarPicker/calendarPicker.js @@ -52,14 +52,21 @@ class CalendarPicker extends Component { }); } + renderDayContents(day) { + return ( + {day.format("D")} + ); + } + render() { - const { focusedInput, startDate, endDate } = this.state; + const { focusedInput, startDate, endDate, renderDayContents } = this.state; const props = omit(this.props, ["autoFocus", "autoFocusEndDate", "initialStartDate", "initialEndDate"]); return ( false, isDayHighlighted: () => false, @@ -126,7 +133,7 @@ CalendarPicker.propTypes = { onOutsideClick: PropTypes.func, onPrevMonthClick: PropTypes.func, renderCalendarInfo: PropTypes.func, - renderDay: PropTypes.func, + renderDayContents: PropTypes.func, withPortal: PropTypes.bool }; diff --git a/imports/plugins/core/ui/client/components/forms/form.js b/imports/plugins/core/ui/client/components/forms/form.js index e8a72e7029d..9a95fb836de 100644 --- a/imports/plugins/core/ui/client/components/forms/form.js +++ b/imports/plugins/core/ui/client/components/forms/form.js @@ -82,7 +82,7 @@ class Form extends Component { } get schema() { - return this.props.schema._schema; + return this.props.schema.mergedSchema(); } valueForField(fieldName) { @@ -226,7 +226,7 @@ class Form extends Component { let fieldHasError = false; if (this.state.isValid === false) { - this.state.schema._invalidKeys + this.state.schema.validationErrors() .filter((v) => v.name === field.name) .forEach((validationError) => { const message = this.state.schema.keyErrorMessage(validationError.name); @@ -255,14 +255,26 @@ class Form extends Component { } renderField(field, additionalFieldProps) { + const { schema } = this.props; const { fieldName } = field; if (this.isFieldHidden(fieldName) === false) { - const fieldSchema = this.schema[fieldName]; + const fieldSchema = schema.getDefinition(fieldName); + + // Get the type from the schema, as a typeof string + const fieldType = fieldSchema.type[0].type; + let fieldTypeString; + if (fieldType === "SimpleSchema.Integer") { + fieldTypeString = "number"; + } else { + // This assumes that oneOf is not used for any schema types + fieldTypeString = typeof fieldType(); + } + const fieldProps = { ...fieldSchema, name: fieldName, - type: typeof fieldSchema.type(), + type: fieldTypeString, ...additionalFieldProps }; diff --git a/imports/plugins/core/ui/client/components/index.js b/imports/plugins/core/ui/client/components/index.js index 2d8ead45f3a..1781d1bb3ee 100644 --- a/imports/plugins/core/ui/client/components/index.js +++ b/imports/plugins/core/ui/client/components/index.js @@ -16,7 +16,7 @@ export { default as Tooltip } from "./tooltip/tooltip"; export { Metadata, Metafield } from "./metadata"; export { TagList, TagItem } from "./tags"; export * from "./cards"; -export { MediaGallery, MediaItem } from "./media"; +export { MediaGallery, MediaItem, MediaUploader } from "./media"; export { default as FlatButton } from "./button/flatButton"; export { SortableTable, SortableTableLegacy } from "./table"; export { Checkbox, RolloverCheckbox } from "./checkbox"; diff --git a/imports/plugins/core/ui/client/components/media/index.js b/imports/plugins/core/ui/client/components/media/index.js index bc4380392ab..1491c8883c2 100644 --- a/imports/plugins/core/ui/client/components/media/index.js +++ b/imports/plugins/core/ui/client/components/media/index.js @@ -1,2 +1,3 @@ export { default as MediaGallery } from "./mediaGallery"; -export { default as MediaItem } from "./media"; +export { default as MediaItem } from "./mediaItem"; +export { default as MediaUploader } from "./mediaUploader"; diff --git a/imports/plugins/core/ui/client/components/media/media.js b/imports/plugins/core/ui/client/components/media/media.js deleted file mode 100644 index 311cb894b0d..00000000000 --- a/imports/plugins/core/ui/client/components/media/media.js +++ /dev/null @@ -1,189 +0,0 @@ -import React, { Component } from "react"; -import classnames from "classnames"; -import PropTypes from "prop-types"; -import ReactImageMagnify from "react-image-magnify"; -import { Components, registerComponent } from "@reactioncommerce/reaction-components"; -import { Reaction } from "/client/api"; -import { SortableItem } from "../../containers"; -import Hint from "./hint"; - -class MediaItem extends Component { - handleMouseEnter = (event) => { - if (this.props.onMouseEnter) { - this.props.onMouseEnter(event, this.props.source); - } - } - - handleMouseLeave = (event) => { - if (this.props.onMouseLeave) { - this.props.onMouseLeave(event, this.props.source); - } - } - - handleRemoveMedia = (event) => { - event.stopPropagation(); - - if (this.props.onRemoveMedia) { - this.props.onRemoveMedia(this.props.source); - } - } - - renderRevision() { - if (this.props.revision) { - if (this.props.revision.changeType === "remove") { - return ( - - ); - } - return ( - - ); - } - return undefined; - } - - renderControls() { - if (this.props.editable) { - // If we have a pending remove, don't show the remove button - if (!this.props.revision || this.props.revision.changeType !== "remove") { - return ( -
- {this.renderRevision()} - -
- ); - } - return ( -
- {this.renderRevision()} -
- ); - } - return null; - } - - get defaultSource() { - return this.props.defaultSource || "/resources/placeholder.gif"; - } - - renderSource = (size) => { - // Set source to be default image - let source = this.defaultSource; - - // Set default source size to `large` - let sourceSize = "&store=large"; - // If size is provided, set that sourse to that size - if (size) { - sourceSize = `&store=${size}`; - } - - // If a source was provided, use it - if (this.props.source) { - if (typeof this.props.source === "object" && this.props.source.url()) { - source = `${this.props.source.url()}${sourceSize}`; - } else { - source = `${this.props.source}${sourceSize}`; - } - } - - return source; - } - - renderImage() { - if (this.props.zoomable && !this.props.editable) { - return ( - - ); - } - return ( - - ); - } - - render() { - const classes = { - "gallery-image": true, - "no-fade-on-hover": this.props.zoomable && !this.props.editable, - "admin-gallery-image": Reaction.hasAdminAccess() - }; - const mediaElement = ( -
- {this.renderImage()} - {this.renderControls()} -
- ); - - if (this.props.editable) { - return this.props.connectDragSource(this.props.connectDropTarget(mediaElement)); - } - - return mediaElement; - } -} - -MediaItem.propTypes = { - connectDragSource: PropTypes.func, - connectDropTarget: PropTypes.func, - defaultSource: PropTypes.string, - editable: PropTypes.bool, - isFeatured: PropTypes.bool, - mediaHeight: PropTypes.number, - mediaWidth: PropTypes.number, - metadata: PropTypes.object, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - onRemoveMedia: PropTypes.func, - revision: PropTypes.object, - source: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - zoomable: PropTypes.bool -}; - -registerComponent("MediaItem", MediaItem, SortableItem("media")); - -export default SortableItem("media")(MediaItem); diff --git a/imports/plugins/core/ui/client/components/media/mediaGallery.js b/imports/plugins/core/ui/client/components/media/mediaGallery.js index 803da5df26c..ba72e2e5204 100644 --- a/imports/plugins/core/ui/client/components/media/mediaGallery.js +++ b/imports/plugins/core/ui/client/components/media/mediaGallery.js @@ -7,27 +7,47 @@ import { Components } from "@reactioncommerce/reaction-components"; import { Reaction } from "/client/api"; class MediaGallery extends Component { + static propTypes = { + editable: PropTypes.bool, + featuredMedia: PropTypes.object, + media: PropTypes.arrayOf(PropTypes.object), + mediaGalleryHeight: PropTypes.number, + mediaGalleryWidth: PropTypes.number, + onDrop: PropTypes.func, + onMouseEnterMedia: PropTypes.func, + onMouseLeaveMedia: PropTypes.func, + onMoveMedia: PropTypes.func, + onRemoveMedia: PropTypes.func, + uploadProgress: PropTypes.shape({ + bytesUploaded: PropTypes.number.isRequired, + bytesTotal: PropTypes.number.isRequired, + percentage: PropTypes.number.isRequired + }) + }; + + static defaultProps = { + onDrop() {}, + onMouseEnterMedia() {}, + onMouseLeaveMedia() {}, + onMoveMedia() {}, + onRemoveMedia() {} + }; + constructor() { super(); + this.state = { dimensions: { width: -1, height: -1 } }; - this.onDrop = this.onDrop.bind(this); } get hasMedia() { - return Array.isArray(this.props.media) && this.props.media.length > 0; - } - - get allowFeaturedMediaHover() { - if (this.props.allowFeaturedMediaHover && this.props.featuredMedia) { - return true; - } + const { media } = this.props; - return false; + return Array.isArray(media) && media.length > 0; } get featuredMedia() { @@ -35,15 +55,13 @@ class MediaGallery extends Component { } handleDropClick = () => { - this.refs.dropzone.open(); - } + this.dropzone && this.dropzone.open(); + }; - onDrop(files) { - if (files.length === 0) { - return; - } + handleDrop = (files) => { + if (files.length === 0) return; return this.props.onDrop(files); - } + }; renderAddItem() { if (this.props.editable) { @@ -77,100 +95,99 @@ class MediaGallery extends Component { return null; } + renderProgressItem() { + // For now, we are not showing actual progress, but we could + return ( +
+ +
+ +
+
+ ); + } + renderFeaturedMedia() { + const { editable, onMouseEnterMedia, onMoveMedia, onRemoveMedia } = this.props; const { width, height } = this.state.dimensions; - if (this.hasMedia) { - return this.props.media.map((media, index) => { - if (index === 0) { - return ( - { - this.setState({ dimensions: contentRect.bounds }); - }} - > - {({ measureRef }) => -
- -
- } -
- ); - } - return null; - }); - } + const media = this.featuredMedia; + if (!media) return ; return ( - + { + const dimensions = { ...contentRect.bounds }; + + // We get React warnings in console when the bounds height comes in as zero + if (dimensions.height === 0) dimensions.height = -1; + + this.setState({ dimensions }); + }} + > + {({ measureRef }) => +
+ +
+ } +
); } renderMediaThumbnails() { - if (this.hasMedia) { - return this.props.media.map((media, index) => ( - - )); - } - return null; + const { editable, media: mediaList, onMouseEnterMedia, onMoveMedia, onRemoveMedia } = this.props; + + return (mediaList || []).map((media, index) => ( + + )); } renderMediaGalleryUploader() { - const containerWidth = this.props.mediaGalleryWidth; + const { mediaGalleryWidth: containerWidth, uploadProgress } = this.props; const classes = { "admin-featuredImage": Reaction.hasAdminAccess() }; - let featured = this.renderAddItem(); - let gallery; - - // Only render media only if there is any - if (this.hasMedia) { - featured = this.renderFeaturedMedia(); - gallery = this.renderMediaThumbnails(); - } return (
{ this.dropzone = inst; }} accept="image/jpg, image/png, image/jpeg" >
-
- {featured} +
+ {this.featuredMedia ? this.renderFeaturedMedia() : this.renderAddItem()}
- {gallery} + {!!this.hasMedia && this.renderMediaThumbnails()} + {!!uploadProgress && this.renderProgressItem()} {this.renderAddItem()}
@@ -186,7 +203,7 @@ class MediaGallery extends Component { return (
-
+
{this.renderFeaturedMedia()}
@@ -198,26 +215,23 @@ class MediaGallery extends Component { } render() { - if (this.props.editable) { - return this.renderMediaGalleryUploader(); + const { editable } = this.props; + + let gallery; + if (editable) { + gallery = this.renderMediaGalleryUploader(); + } else { + gallery = this.renderMediaGallery(); } - return this.renderMediaGallery(); + // Note that only editable mode actually uses drag-drop, but since both views render + // MediaItems, which are SortableItems, there is an error if it isn't in the ancester tree + return ( + + {gallery} + + ); } } -MediaGallery.propTypes = { - allowFeaturedMediaHover: PropTypes.bool, - editable: PropTypes.bool, - featuredMedia: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - media: PropTypes.arrayOf(PropTypes.object), - mediaGalleryHeight: PropTypes.number, - mediaGalleryWidth: PropTypes.number, - onDrop: PropTypes.func, - onMouseEnterMedia: PropTypes.func, - onMouseLeaveMedia: PropTypes.func, - onMoveMedia: PropTypes.func, - onRemoveMedia: PropTypes.func -}; - export default MediaGallery; diff --git a/imports/plugins/core/ui/client/components/media/mediaItem.js b/imports/plugins/core/ui/client/components/media/mediaItem.js new file mode 100644 index 00000000000..b8ae17ca626 --- /dev/null +++ b/imports/plugins/core/ui/client/components/media/mediaItem.js @@ -0,0 +1,202 @@ +import React, { Component } from "react"; +import classnames from "classnames"; +import PropTypes from "prop-types"; +import ReactImageMagnify from "react-image-magnify"; +import { Components, registerComponent } from "@reactioncommerce/reaction-components"; +import { Reaction } from "/client/api"; +import { SortableItem } from "/imports/plugins/core/ui/client/containers"; +import Hint from "./hint"; + +class MediaItem extends Component { + static propTypes = { + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + defaultSource: PropTypes.string, + editable: PropTypes.bool, + mediaHeight: PropTypes.number, + mediaWidth: PropTypes.number, + onClick: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onRemoveMedia: PropTypes.func, + size: PropTypes.string, + source: PropTypes.object, + zoomable: PropTypes.bool + }; + + static defaultProps = { + defaultSource: "/resources/placeholder.gif", + editable: false, + onClick() {}, + onMouseEnter() {}, + onMouseLeave() {}, + onRemoveMedia() {}, + size: "large", + zoomable: false + }; + + handleClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + + this.props.onClick(); + }; + + handleKeyPress = (event) => { + if (event.keyCode === 13) this.handleClick(event); + }; + + handleMouseEnter = (event) => { + const { onMouseEnter, source } = this.props; + onMouseEnter(event, source); + } + + handleMouseLeave = (event) => { + const { onMouseLeave, source } = this.props; + onMouseLeave(event, source); + } + + handleRemoveMedia = (event) => { + const { onRemoveMedia, source } = this.props; + + event.stopPropagation(); + onRemoveMedia(source); + } + + renderRevision() { + const { source } = this.props; + const { revision } = source || {}; + + if (!revision) return null; + + if (revision.changeType === "remove") { + return ( + + ); + } + + return ( + + ); + } + + renderControls() { + const { editable, source } = this.props; + + if (!editable) return null; + + const { revision } = source || {}; + + // If we have a pending remove, don't show the remove button + let removeButton = null; + if (!revision || revision.changeType !== "remove") { + removeButton = ( + + ); + } + + return ( +
+ {this.renderRevision()} + {removeButton} +
+ ); + } + + getSource = (size) => { + const { defaultSource, source } = this.props; + + return (source && source.url({ store: size })) || defaultSource; + }; + + renderImage() { + const { editable, mediaHeight, mediaWidth, size, zoomable } = this.props; + + if (zoomable && !editable) { + return ( + + ); + } + + return ( + + ); + } + + render() { + const { connectDragSource, connectDropTarget, editable, zoomable } = this.props; + + const classes = { + "gallery-image": true, + "no-fade-on-hover": zoomable && !editable, + "admin-gallery-image": Reaction.hasAdminAccess() + }; + + const mediaElement = ( +
+ {this.renderImage()} + {this.renderControls()} +
+ ); + + if (editable) { + return connectDragSource(connectDropTarget(mediaElement)); + } + + return mediaElement; + } +} + +registerComponent("MediaItem", MediaItem, SortableItem("media")); + +export default SortableItem("media")(MediaItem); diff --git a/imports/plugins/core/ui/client/components/media/mediaUploader.js b/imports/plugins/core/ui/client/components/media/mediaUploader.js new file mode 100644 index 00000000000..625fa19002d --- /dev/null +++ b/imports/plugins/core/ui/client/components/media/mediaUploader.js @@ -0,0 +1,82 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import Dropzone from "react-dropzone"; +import { FileRecord } from "@reactioncommerce/file-collections"; +import { Components, registerComponent } from "@reactioncommerce/reaction-components"; +import { Logger } from "/client/api"; +import { Media } from "/imports/plugins/core/files/client"; + +class MediaUploader extends Component { + static propTypes = { + metadata: PropTypes.object + }; + + constructor(props) { + super(props); + + this.state = { + isUploading: false + }; + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + uploadFiles = (acceptedFiles) => { + const { metadata } = this.props; + const filesArray = Array.from(acceptedFiles); + if (filesArray.length === 0) return; + + this.setState({ isUploading: true }); + + const promises = []; + filesArray.forEach((browserFile) => { + const fileRecord = FileRecord.fromFile(browserFile); + + if (metadata) fileRecord.metadata = metadata; + + const promise = fileRecord.upload({}) + // We insert only AFTER the server has confirmed that all chunks were uploaded + .then(() => Media.insert(fileRecord)) + .catch((error) => { + Logger.error(error); + }); + + promises.push(promise); + }); + + Promise.all(promises).then(() => { + if (!this._isMounted) return; + this.setState({ isUploading: false }); + }); + }; + + render() { + const { isUploading } = this.state; + + return ( + +
+ {!!isUploading &&
} + {!isUploading && + + } +
+
+ ); + } +} + +registerComponent("MediaUploader", MediaUploader); + +export default MediaUploader; diff --git a/imports/plugins/core/ui/client/components/numericInput/numericInput.html b/imports/plugins/core/ui/client/components/numericInput/numericInput.html deleted file mode 100644 index 9c003cb35f1..00000000000 --- a/imports/plugins/core/ui/client/components/numericInput/numericInput.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/imports/plugins/core/ui/client/components/numericInput/numericInput.js b/imports/plugins/core/ui/client/components/numericInput/numericInput.js index e280196450e..ca67718b489 100644 --- a/imports/plugins/core/ui/client/components/numericInput/numericInput.js +++ b/imports/plugins/core/ui/client/components/numericInput/numericInput.js @@ -4,31 +4,14 @@ import classnames from "classnames"; import accounting from "accounting-js"; import { registerComponent } from "@reactioncommerce/reaction-components"; -function setCaretPosition(ctrl, pos) { - if (ctrl.setSelectionRange) { - ctrl.focus(); - ctrl.setSelectionRange(pos, pos); - } else if (ctrl.createTextRange) { - const range = ctrl.createTextRange(); - - range.collapse(true); - range.moveEnd("character", pos); - range.moveStart("character", pos); - range.select(); - } -} class NumericInput extends Component { constructor(props) { super(props); - - // Set default state this.state = { - value: this.props.value + value: this.props.value, + isEditing: false }; - - // Bind event handlers - this.handleChange = this.handleChange.bind(this); } /** @@ -37,66 +20,95 @@ class NumericInput extends Component { * @return {undefined} */ componentWillReceiveProps(nextProps) { - this.setState({ - value: nextProps.value - }); - } - - get moneyFormat() { - const moneyFormat = this.props.format || {}; - // precision is mis-represented in accounting.js. Precision in this case is actually scale - // so we add the property for precision based on scale. - moneyFormat.precision = moneyFormat.scale !== undefined ? moneyFormat.scale : 2; - - return moneyFormat; + if (nextProps.value !== undefined && !this.state.isEditing) { + const value = this.format(nextProps.value); + this.setState({ + value + }); + } } + /** + * Gets the displayed value. If in edit mode, + * the field's value is not formatted. If not in + * edit mode, the field gets formatted according to chosen locale. + * @returns {*} + */ get displayValue() { const { value } = this.state; - - if (typeof value === "number") { - if (this.props.format && this.props.format.scale === 0) { - return this.format(value * 100); - } - return this.format(value); + if (this.state.isEditing) { + return value; } - - return 0; + return this.format(value); } - get scale() { - const parts = this.state.value.split("."); - - if (parts.length === 2) { - return parts[1].length; + /** + * Format this inputs value to a numeric string + * @return {String} Formatted numeric string + */ + format(value) { + const moneyFormat = Object.assign({}, this.props.format); + if (this.state.isEditing) { + moneyFormat.symbol = ""; // No currency sign in edit mode } - - return 0; + const unformattedValue = this.unformat(value); + const formatted = accounting.formatMoney(unformattedValue, moneyFormat).trim(); + return formatted; } /** - * format a numeric string - * @param {String} value Value to format - * @param {Object} format Object containing settings for formatting value - * @return {String} Foramtted numeric string + * Get the field's value as rational number + * @param { Number } the field's value */ - format(value, format) { - const moneyFormat = format || this.moneyFormat; + unformat(value) { + const unformattedValue = accounting.unformat(value, this.props.format.decimal); + return unformattedValue; + } - const decimal = moneyFormat.decimal || undefined; - const unformatedValue = this.unformat(value, decimal); + /** + * onBlur + * @summary set the state when the value of the input is changed + * @param {Event} event Event object + * @return {void} + */ + onBlur = () => { + let { value } = this.state; + if (value > this.props.maxValue) { + value = this.props.maxValue; + } + this.setState({ + isEditing: false, + value + }); + } - return accounting.formatMoney(unformatedValue, moneyFormat); + /** + * Selects the text of the passed input field + * @param ctrl + */ + selectAll(ctrl) { + if (ctrl.setSelectionRange) { + ctrl.setSelectionRange(0, ctrl.value.length); + } } /** - * unformat numeric string - * @param {String} value String value to unformat - * @param {String} decimal String representing the decimal place - * @return {String} unformatted numeric string + * onFocus + * @summary set the state when the input is focused + * @param {Event} event Event object + * @return {void} */ - unformat(value, decimal) { - return accounting.unformat(value, decimal); + onFocus = (event) => { + const { currentTarget } = event; + this.setState({ + isEditing: true + }, () => { + this.setState({ + value: this.format(this.state.value) + }, () => { + this.selectAll(currentTarget); + }); + }); } /** @@ -104,25 +116,16 @@ class NumericInput extends Component { * @param {SyntheticEvent} event Change event * @return {undefined} */ - handleChange(event) { - const input = event.currentTarget; + handleChange = (event) => { const { value } = event.currentTarget; - let numberValue = this.unformat(value); - - if (this.props.format.scale === 0) { - numberValue /= 100; - } - this.setState({ - value: numberValue, - caretPosition: input.selectionStart - }, () => { - setCaretPosition(input, Math.max(this.state.caretPosition, 0)); - - if (this.props.onChange) { - this.props.onChange(event, { value, numberValue }); - } + value }); + + if (this.props.onChange) { + const numberValue = this.unformat(value); + this.props.onChange(event, { value, numberValue }); + } } /** @@ -132,7 +135,6 @@ class NumericInput extends Component { render() { const { classNames } = this.props; - if (this.props.isEditing === false) { const textValueClassName = classnames({ rui: true, @@ -157,6 +159,8 @@ class NumericInput extends Component { @@ -175,9 +179,10 @@ NumericInput.propTypes = { classNames: PropTypes.shape({}), disabled: PropTypes.bool, format: PropTypes.shape({ - scale: PropTypes.number + decimal: PropTypes.number }), isEditing: PropTypes.bool, + maxValue: PropTypes.number, onChange: PropTypes.func, value: PropTypes.number }; diff --git a/imports/plugins/core/ui/client/components/popover/popover.js b/imports/plugins/core/ui/client/components/popover/popover.js index 1306d5cb5f7..535f7d7caf7 100644 --- a/imports/plugins/core/ui/client/components/popover/popover.js +++ b/imports/plugins/core/ui/client/components/popover/popover.js @@ -101,6 +101,10 @@ class Popover extends Component { render() { return ( diff --git a/imports/plugins/core/ui/client/components/select/select.html b/imports/plugins/core/ui/client/components/select/select.html deleted file mode 100644 index 00706541d7b..00000000000 --- a/imports/plugins/core/ui/client/components/select/select.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - diff --git a/imports/plugins/core/ui/client/components/select/select.js b/imports/plugins/core/ui/client/components/select/select.js deleted file mode 100644 index e7086fab3a3..00000000000 --- a/imports/plugins/core/ui/client/components/select/select.js +++ /dev/null @@ -1,112 +0,0 @@ -import { Template } from "meteor/templating"; -import { ReactiveDict } from "meteor/reactive-dict"; -import { templateClassName } from "/imports/plugins/core/ui/client/helpers/helpers"; - -/** - * Select - onCreated - */ -Template.select.onCreated(function () { - this.state = new ReactiveDict(); -}); - -/** - * Select - events - */ -Template.select.events({ - "change select, change input"(event, template) { - if (template.data.onSelect) { - template.data.onSelect(event.target.value, event); - } - } -}); - -/** - * Select - helpers - */ -Template.select.helpers({ - template() { - const currentData = Template.currentData(); - - if (!currentData) { - return; - } - - const { type } = currentData; - if (type === "radios" || type === "radio") { - return "selectAsRadioButtons"; - } else if (type === "checkboxes" || type === "checkbox") { - return "selectAsCheckboxes"; - } - - return "selectAsDropdown"; - } -}); - -/** - * Select (As a set of radio buttons) - helpers - */ -Template.selectAsRadioButtons.helpers({ - itemListClassName() { - return templateClassName(Template.instance(), { - rui: true, - items: true, - flex: true, - quarter: true - }, "itemList"); - }, - - itemClassName() { - return templateClassName(Template.instance(), { - rui: true, - item: true - }, "item"); - }, - - labelClassName() { - return templateClassName(Template.instance(), undefined, "label"); - }, - - inputClassName() { - return templateClassName(Template.instance(), undefined, "input"); - }, - - templateData(option) { - const instance = Template.instance(); - const { data } = instance; - - return { - selected: data.selected === option[data.key || "_id"], - option - }; - }, - /** - * checked attribute helper - * @param {Object} option Option object - * @return {String|undefined} returns "chekced" if selected, undefined otherwise - */ - checked(option) { - const data = Template.currentData(); - - if (data.selected === option[data.key || "_id"]) { - return "checked"; - } - } -}); - -/** - * Select (As a set of checkboxes) - helpers - */ -Template.selectAsCheckboxes.helpers({ - /** - * checked attribute helper - * @param {Object} option Option object - * @return {String|undefined} returns "chekced" if selected, undefined otherwise - */ - checked(option) { - const data = Template.currentData(); - - if (data.selected === option[data.key || "_id"]) { - return "checked"; - } - } -}); diff --git a/imports/plugins/core/ui/client/components/tags/tagItem.js b/imports/plugins/core/ui/client/components/tags/tagItem.js index a15dc5bce50..82f80954365 100644 --- a/imports/plugins/core/ui/client/components/tags/tagItem.js +++ b/imports/plugins/core/ui/client/components/tags/tagItem.js @@ -7,8 +7,8 @@ import "velocity-animate/velocity.ui"; import { registerComponent } from "@reactioncommerce/reaction-components"; import { i18next } from "/client/api"; import { Button, Handle } from "/imports/plugins/core/ui/client/components"; +import { SortableItem } from "/imports/plugins/core/ui/client/containers"; import { Router } from "@reactioncommerce/reaction-router"; -import { SortableItem } from "../../containers"; class TagItem extends Component { componentWillReceiveProps(nextProps) { diff --git a/imports/plugins/core/ui/client/components/upload/upload.html b/imports/plugins/core/ui/client/components/upload/upload.html deleted file mode 100644 index 24f9bee6732..00000000000 --- a/imports/plugins/core/ui/client/components/upload/upload.html +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/imports/plugins/core/ui/client/components/upload/upload.js b/imports/plugins/core/ui/client/components/upload/upload.js deleted file mode 100644 index 53f514e6c35..00000000000 --- a/imports/plugins/core/ui/client/components/upload/upload.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * uploadHandler method - */ - -import { Template } from "meteor/templating"; -import { $ } from "meteor/jquery"; - -function uploadHandler(event, instance) { - const files = []; - - FS.Utility.eachFile(event, (file) => { - files.push(new FS.File(file)); - }); - - if (instance.data.onUpload) { - instance.data.onUpload(files, event); - } - - return files; -} - -Template.upload.helpers({ - uploadButtonProps() { - const instance = Template.instance(); - return { - className: "btn-block", - label: instance.data.label || "Drop file to upload", - i18nKeyLabel: instance.data.i18nKeyLabel || "productDetail.dropFiles", - onClick() { - instance.$("input[name=files]").click(); - } - }; - } -}); - -Template.upload.events({ - "click #btn-upload"() { - return $("#files").click(); - }, - "change input[name=files]": uploadHandler, - "dropped .js-dropzone": uploadHandler -}); diff --git a/imports/plugins/core/ui/client/containers/mediaGallery.js b/imports/plugins/core/ui/client/containers/mediaGallery.js index 8092cefb63f..4d477028265 100644 --- a/imports/plugins/core/ui/client/containers/mediaGallery.js +++ b/imports/plugins/core/ui/client/containers/mediaGallery.js @@ -5,50 +5,14 @@ import update from "immutability-helper"; import { compose } from "recompose"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import _ from "lodash"; +import { FileRecord } from "@reactioncommerce/file-collections"; import { Meteor } from "meteor/meteor"; import MediaGallery from "../components/media/mediaGallery"; -import { Reaction } from "/client/api"; +import { Logger, Reaction } from "/client/api"; import { ReactionProduct } from "/lib/api"; -import { Media, Revisions } from "/lib/collections"; - -function uploadHandler(files) { - // TODO: It would be cool to move this logic to common ValidatedMethod, but - // I can't find a way to do this, because of browser's `FileList` collection - // and it `Blob`s which is our event.target.files. - // There is a way to do this: http://stackoverflow.com/a/24003932. but it's too - // tricky - const productId = ReactionProduct.selectedProductId(); - const variant = ReactionProduct.selectedVariant(); - if (typeof variant !== "object") { - return Alerts.add("Please, create new Variant first.", "danger", { - autoHide: true - }); - } - const variantId = variant._id; - const shopId = ReactionProduct.selectedProduct().shopId || Reaction.getShopId(); - const userId = Meteor.userId(); - let count = Media.find({ - "metadata.variantId": variantId - }).count(); - - for (const file of files) { - const fileObj = new FS.File(file); - - fileObj.metadata = { - ownerId: userId, - productId, - variantId, - shopId, - priority: count, - toGrid: 1 // we need number - }; - - Media.insert(fileObj); - count += 1; - } - - return true; -} +import { Revisions } from "/lib/collections"; +import { isRevisionControlEnabled } from "/imports/plugins/core/revisions/lib/api"; +import { Media } from "/imports/plugins/core/files/client"; const wrapComponent = (Comp) => ( class MediaGalleryContainer extends Component { @@ -64,27 +28,32 @@ const wrapComponent = (Comp) => ( super(props); this.state = { - featuredMedia: props.media[0], dimensions: { width: -1, height: -1 - } + }, + featuredMedia: null, + media: null, + uploadProgress: null }; } componentWillReceiveProps(nextProps) { - this.setState({ - featuredMedia: nextProps.media[0], - media: nextProps.media - }); - } + // We need to do this logic only if we've temporarily set media in state for latency compensation + if (!this.state.media) return; + + const previousMediaIds = (this.props.media || []).map(({ _id }) => _id); + const nextMediaIds = (nextProps.media || []).map(({ _id }) => _id); - handleDrop = (files) => { - uploadHandler(files); + // If added, moved, or reordered media items since last render, then we can assume + // we got updated data in subscription, clear state, and go back to using the prop + if (JSON.stringify(previousMediaIds) !== JSON.stringify(nextMediaIds)) { + this.setState({ media: null }); + } } handleRemoveMedia = (media) => { - const imageUrl = media.url(); + const imageUrl = media.url({ store: "medium" }); const mediaId = media._id; Alerts.alert({ @@ -95,48 +64,48 @@ const wrapComponent = (Comp) => ( imageHeight: 150 }, (isConfirm) => { if (isConfirm) { - Media.remove({ _id: mediaId }, (error) => { + Media.remove(mediaId, (error) => { if (error) { Alerts.toast(error.reason, "warning", { autoHide: 10000 }); } - - // updateImagePriorities(); }); } // show media as removed (since it will not disappear until changes are published }); - } - - get allowFeaturedMediaHover() { - if (this.state.featuredMedia) { - return true; - } - return false; - } + }; get media() { - return (this.state && this.state.media) || this.props.media; + return this.state.media || this.props.media; } handleMouseEnterMedia = (event, media) => { - this.setState({ - featuredMedia: media - }); - } + const { editable } = this.props; + + // It is confusing for an admin to know what the actual featured media is if it + // changes on hover of the other media. + if (!editable) { + this.setState({ featuredMedia: media }); + } + }; handleMouseLeaveMedia = () => { - this.setState({ - featuredMedia: undefined - }); - } + const { editable } = this.props; + + // It is confusing for an admin to know what the actual featured media is if it + // changes on hover of the other media. + if (!editable) { + this.setState({ featuredMedia: null }); + } + }; handleMoveMedia = (dragIndex, hoverIndex) => { - const media = this.props.media[dragIndex]; + const mediaList = this.media; + const media = mediaList[dragIndex]; // Apply new sort order to variant list - const newMediaOrder = update(this.props.media, { + const newMediaOrder = update(mediaList, { $splice: [ [dragIndex, 1], [hoverIndex, 0, media] @@ -145,21 +114,69 @@ const wrapComponent = (Comp) => ( // Set local state so the component does't have to wait for a round-trip // to the server to get the updated list of variants - this.setState({ - media: newMediaOrder - }); + this.setState({ media: newMediaOrder }); // Save the updated positions - Meteor.defer(() => { - newMediaOrder.forEach((mediaItem, index) => { - Media.update(mediaItem._id, { - $set: { - "metadata.priority": index - } + const sortedMediaIDs = newMediaOrder.map(({ _id }) => _id); + Meteor.call("media/updatePriorities", sortedMediaIDs, (error) => { + if (error) { + // Go back to using media prop instead of media state so that it doesn't appear successful + this.setState({ media: null }); + + Alerts.toast(error.reason, "warning", { + autoHide: 10000 }); - }); + } }); - } + }; + + handleUpload = (files) => { + const productId = ReactionProduct.selectedProductId(); + const variant = ReactionProduct.selectedVariant(); + if (typeof variant !== "object") { + return Alerts.add("Select a variant", "danger", { autoHide: true }); + } + const variantId = variant._id; + const shopId = ReactionProduct.selectedProduct().shopId || Reaction.getShopId(); + const userId = Meteor.userId(); + let count = Media.findLocal({ + "metadata.variantId": variantId + }).length; + + for (const file of files) { + // Convert it to a FileRecord + const fileRecord = FileRecord.fromFile(file); + + // Set metadata + fileRecord.metadata = { + ownerId: userId, + productId, + variantId, + shopId, + priority: count, + toGrid: 1 // we need number + }; + + count += 1; + + // Listen for upload progress events + fileRecord.on("uploadProgress", (uploadProgress) => { + this.setState({ uploadProgress }); + }); + + // Do the upload. chunkSize is optional and defaults to 5MB + fileRecord.upload({}) + // We insert only AFTER the server has confirmed that all chunks were uploaded + .then(() => Media.insert(fileRecord)) + .then(() => { + this.setState({ uploadProgress: null }); + }) + .catch((error) => { + this.setState({ uploadProgress: null }); + Logger.error(error); + }); + } + }; render() { const { width, height } = this.state.dimensions; @@ -171,13 +188,11 @@ const wrapComponent = (Comp) => ( this.setState({ dimensions: contentRect.bounds }); }} > - {({ measureRef }) =>
( media={this.media} mediaGalleryHeight={height} mediaGalleryWidth={width} + uploadProgress={this.state.uploadProgress} />
} @@ -209,13 +225,19 @@ function fetchMediaRevisions() { // resort the media in function sortMedia(media) { - const sortedMedia = _.sortBy(media, (m) => m.metadata.priority); + const sortedMedia = _.sortBy(media, (m) => { + const { priority } = (m && m.metadata) || {}; + if (!priority && priority !== 0) { + return 1000; + } + return priority; + }); return sortedMedia; } // Search through revisions and if we find one for the image, stick it on the object function appendRevisionsToMedia(props, media) { - if (!Reaction.hasPermission(props.permission || ["createProduct"])) { + if (!isRevisionControlEnabled() || !Reaction.hasPermission(props.permission || ["createProduct"])) { return media; } const mediaRevisions = fetchMediaRevisions(); @@ -238,9 +260,7 @@ function composer(props, onData) { let editable; const viewAs = Reaction.getUserPreferences("reaction-dashboard", "viewAs", "administrator"); - if (!props.media) { - // Fetch media based on props - } else { + if (props.media) { media = appendRevisionsToMedia(props, props.media); } diff --git a/imports/plugins/core/ui/client/containers/sortableItem.js b/imports/plugins/core/ui/client/containers/sortableItem.js index 9b67ab40137..a73c4497725 100644 --- a/imports/plugins/core/ui/client/containers/sortableItem.js +++ b/imports/plugins/core/ui/client/containers/sortableItem.js @@ -2,14 +2,6 @@ import React from "react"; import PropTypes from "prop-types"; import { DragSource, DropTarget } from "react-dnd"; -const cardSource = { - beginDrag(props) { - return { - index: props.index - }; - } -}; - /** * Specifies the props to inject into your component. * @param {DragSourceConnector} connect An onject containing functions to assign roles to a component's DOM nodes @@ -19,7 +11,6 @@ const cardSource = { function collectDropSource(connect, monitor) { return { connectDragSource: connect.dragSource(), - connectDragPreview: connect.dragPreview(), isDragging: monitor.isDragging() }; } @@ -30,6 +21,15 @@ function collectDropTarget(connect) { }; } +const cardSource = { + beginDrag(props) { + const { index } = props; + return { + index + }; + } +}; + const cardTarget = { hover(props, monitor) { const dragIndex = monitor.getItem().index; @@ -48,6 +48,9 @@ const cardTarget = { // but it's good here for the sake of performance // to avoid expensive index searches. monitor.getItem().index = hoverIndex; + }, + drop(props) { + if (typeof props.onDrop === "function") props.onDrop(); } }; @@ -61,7 +64,6 @@ export default function ComposeSortableItem(itemType) { SortableItem.propTypes = { // Injected by React DnD: - connectDragPreview: PropTypes.func.isRequired, connectDragSource: PropTypes.func.isRequired, connectDropTarget: PropTypes.func.isRequired, isDragging: PropTypes.bool.isRequired diff --git a/imports/plugins/core/ui/client/containers/tagListContainer.js b/imports/plugins/core/ui/client/containers/tagListContainer.js index a0ad3b658ff..4f2a85a0771 100644 --- a/imports/plugins/core/ui/client/containers/tagListContainer.js +++ b/imports/plugins/core/ui/client/containers/tagListContainer.js @@ -3,13 +3,12 @@ import PropTypes from "prop-types"; import _ from "lodash"; import update from "immutability-helper"; import { compose } from "recompose"; -import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; +import { registerComponent, composeWithTracker, Components } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; import { Reaction, i18next } from "/client/api"; import TagList from "../components/tags/tagList"; import { Tags } from "/lib/collections"; import { getTagIds } from "/lib/selectors/tags"; -import { DragDropProvider } from "/imports/plugins/core/ui/client/providers"; function updateSuggestions(term, { excludeTags }) { const slug = Reaction.getSlug(term); @@ -214,7 +213,7 @@ const wrapComponent = (Comp) => ( render() { return ( - + ( tooltip="Unpublished changes" {...this.props} /> - + ); } } diff --git a/imports/plugins/core/ui/client/index.js b/imports/plugins/core/ui/client/index.js index 34f2aad30ec..5091a0ae8b2 100644 --- a/imports/plugins/core/ui/client/index.js +++ b/imports/plugins/core/ui/client/index.js @@ -13,17 +13,10 @@ import "./components/cards/cardGroup.html"; import "./components/cards/cards.html"; import "./components/cards/cards.js"; -import "./components/numericInput/numericInput.html"; import "./components/numericInput/numericInput.js"; -import "./components/select/select.html"; -import "./components/select/select.js"; - import "./components/textfield/textfield.html"; -import "./components/upload/upload.html"; -import "./components/upload/upload.js"; - // Safe css import for npm package import "nouislider-algolia-fork/src/nouislider.css"; import "nouislider-algolia-fork/src/nouislider.pips.css"; diff --git a/imports/plugins/core/ui/register.js b/imports/plugins/core/ui/register.js index 056bd5c2a7c..87c95b8a544 100644 --- a/imports/plugins/core/ui/register.js +++ b/imports/plugins/core/ui/register.js @@ -5,7 +5,6 @@ Reaction.registerPackage({ name: "reaction-ui", icon: "fa fa-html5", autoEnable: true, - settings: "", registry: [], layout: [] }); diff --git a/imports/plugins/core/ui/server/index.js b/imports/plugins/core/ui/server/index.js index 3979f964b5a..be4987b5caf 100644 --- a/imports/plugins/core/ui/server/index.js +++ b/imports/plugins/core/ui/server/index.js @@ -1 +1,2 @@ import "./i18n"; +import "./policy"; diff --git a/imports/plugins/core/ui/server/policy.js b/imports/plugins/core/ui/server/policy.js new file mode 100644 index 00000000000..d94fef228d3 --- /dev/null +++ b/imports/plugins/core/ui/server/policy.js @@ -0,0 +1,6 @@ +import { BrowserPolicy } from "meteor/browser-policy-common"; + +BrowserPolicy.content.allowOriginForAll("*.facebook.com"); +BrowserPolicy.content.allowOriginForAll("connect.facebook.net"); +BrowserPolicy.content.allowOriginForAll("fonts.googleapis.com"); +BrowserPolicy.content.allowOriginForAll("fonts.gstatic.com"); diff --git a/imports/plugins/core/versions/server/migrations/10_set_primary_shop.js b/imports/plugins/core/versions/server/migrations/10_set_primary_shop.js index e6b474d76ff..7f2da123d96 100644 --- a/imports/plugins/core/versions/server/migrations/10_set_primary_shop.js +++ b/imports/plugins/core/versions/server/migrations/10_set_primary_shop.js @@ -13,11 +13,11 @@ Migrations.add({ "emails.0.address": { $exists: true } }, { $set: { shopType: "primary" } - }); + }, { bypassCollection2: true }); }, down() { - Shops._collection.update({ shopType: "primary" }, { + Shops.update({ shopType: "primary" }, { $unset: { shopType: "" } - }); + }, { bypassCollection2: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/11_add_product_to_cart_items.js b/imports/plugins/core/versions/server/migrations/11_add_product_to_cart_items.js index 8518caac49e..8d49c13c75c 100644 --- a/imports/plugins/core/versions/server/migrations/11_add_product_to_cart_items.js +++ b/imports/plugins/core/versions/server/migrations/11_add_product_to_cart_items.js @@ -12,7 +12,7 @@ Migrations.add({ }); Cart.update({ _id: cart._id }, { $set: { items: cart.items } - }); + }, { bypassCollection2: true }); } }); @@ -23,7 +23,7 @@ Migrations.add({ }); Orders.update({ _id: order._id }, { $set: { items: order.items } - }); + }, { bypassCollection2: true }); }); }, // Going down, we remove the product object on each item in cart and order @@ -33,9 +33,9 @@ Migrations.add({ cart.items.forEach((item) => { delete item.product; }); - Cart._collection.update({ _id: cart._id }, { + Cart.update({ _id: cart._id }, { $set: { items: cart.items } - }); + }, { bypassCollection2: true }); } }); @@ -43,9 +43,9 @@ Migrations.add({ order.items.forEach((item) => { delete item.product; }); - Orders._collection.update({ _id: order._id }, { + Orders.update({ _id: order._id }, { $set: { items: order.items } - }); + }, { bypassCollection2: true }); }); } }); diff --git a/imports/plugins/core/versions/server/migrations/12_add_shopId_on_billing.js b/imports/plugins/core/versions/server/migrations/12_add_shopId_on_billing.js index 77742c21b72..e918cbc0c45 100644 --- a/imports/plugins/core/versions/server/migrations/12_add_shopId_on_billing.js +++ b/imports/plugins/core/versions/server/migrations/12_add_shopId_on_billing.js @@ -11,27 +11,19 @@ Migrations.add({ Orders.update({}, { $set: { "billing.0.shopId": shopId } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); Cart.update({}, { $set: { "billing.0.shopId": shopId } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); }, down() { Orders.update({}, { $unset: { "billing.0.shopId": "" } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); Cart.update({}, { $set: { "billing.0.shopId": "" } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/13_add_shopId_on_shipping.js b/imports/plugins/core/versions/server/migrations/13_add_shopId_on_shipping.js index 9d0d49d6ec4..1d76af4b9d0 100644 --- a/imports/plugins/core/versions/server/migrations/13_add_shopId_on_shipping.js +++ b/imports/plugins/core/versions/server/migrations/13_add_shopId_on_shipping.js @@ -11,27 +11,19 @@ Migrations.add({ Orders.update({}, { $set: { "shipping.0.shopId": shopId } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); Cart.update({}, { $set: { "shipping.0.shopId": shopId } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); }, down() { Orders.update({}, { $unset: { "shipping.0.shopId": "" } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); Cart.update({}, { $set: { "shipping.0.shopId": "" } - }, { - multi: true - }); + }, { bypassCollection2: true, multi: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/14_rebuild_order_search_collection.js b/imports/plugins/core/versions/server/migrations/14_rebuild_order_search_collection.js index d730a3f1ebc..81427a34d67 100644 --- a/imports/plugins/core/versions/server/migrations/14_rebuild_order_search_collection.js +++ b/imports/plugins/core/versions/server/migrations/14_rebuild_order_search_collection.js @@ -1,18 +1,39 @@ import { Migrations } from "meteor/percolate:migrations"; import { OrderSearch } from "/lib/collections"; -import { buildOrderSearch } from "/imports/plugins/included/search-mongo/server/methods/searchcollections"; +import { Reaction, Logger } from "/server/api"; -Migrations.add({ +let buildOrderSearch; + +async function loadSearchRecordBuilderIfItExists() { + const searchPackage = Reaction.getPackageSettings("reaction-search"); + + if (typeof searchPackage === "object") { + Logger.debug("Found stock search-mongo (reaction-search) plugin."); + + ({ buildOrderSearch } = await import("/imports/plugins/included/search-mongo/server/methods/searchcollections")); + } else { + Logger.warn("Failed to load reaction-search plugin. Skipping building order search records on version migration " + + "step 14."); + } +} + +loadSearchRecordBuilderIfItExists().then(() => Migrations.add({ // Migrations 12 and 13 introduced changes on Orders, so we need to rebuild the search collection version: 14, up() { OrderSearch.remove({}); - buildOrderSearch(); + + if (buildOrderSearch) { + buildOrderSearch(); + } }, down() { // whether we are going up or down we just want to update the search collections // to match whatever the current code in the build methods are. OrderSearch.remove({}); - buildOrderSearch(); + + if (buildOrderSearch) { + buildOrderSearch(); + } } -}); +}), (err) => Logger.warn(`Failed to run version migration step 14. Received error: ${err}.`)); diff --git a/imports/plugins/core/versions/server/migrations/15_update_shipping_status_to_workflow.js b/imports/plugins/core/versions/server/migrations/15_update_shipping_status_to_workflow.js index f99678fe4fb..27a8bd76be3 100644 --- a/imports/plugins/core/versions/server/migrations/15_update_shipping_status_to_workflow.js +++ b/imports/plugins/core/versions/server/migrations/15_update_shipping_status_to_workflow.js @@ -53,7 +53,7 @@ Migrations.add({ Orders.update({ _id: order._id }, { $set: { "shipping.0": currentShipping } - }); + }, { bypassCollection2: true }); }); }, down() { @@ -81,7 +81,7 @@ Migrations.add({ Orders.update({ _id: order._id }, { $set: { "shipping.0": currentShipping } - }); + }, { bypassCollection2: true }); }); } }); diff --git a/imports/plugins/core/versions/server/migrations/16_update_billing_paymentMethod.js b/imports/plugins/core/versions/server/migrations/16_update_billing_paymentMethod.js index b01644a4d6c..c1004814550 100644 --- a/imports/plugins/core/versions/server/migrations/16_update_billing_paymentMethod.js +++ b/imports/plugins/core/versions/server/migrations/16_update_billing_paymentMethod.js @@ -40,7 +40,7 @@ Migrations.add({ Orders.update({ _id: order._id }, { $set: { billing: newBilling } - }); + }, { bypassCollection2: true }); }); }, down() { @@ -51,9 +51,9 @@ Migrations.add({ delete billing.paymentMethod.shopId; delete billing.paymentMethod.items; }); - Orders._collection.update({ _id: order._id }, { + Orders.update({ _id: order._id }, { $set: { items: order.billing } - }); + }, { bypassCollection2: true }); }); } }); diff --git a/imports/plugins/core/versions/server/migrations/17_set_shop_uols.js b/imports/plugins/core/versions/server/migrations/17_set_shop_uols.js index 0f2ca03c75a..2809e93853c 100644 --- a/imports/plugins/core/versions/server/migrations/17_set_shop_uols.js +++ b/imports/plugins/core/versions/server/migrations/17_set_shop_uols.js @@ -23,7 +23,7 @@ Migrations.add({ label: "Feet" }] } - }, { multi: true }); + }, { bypassCollection2: true, multi: true }); }, down() { Shops.update({ @@ -34,6 +34,6 @@ Migrations.add({ baseUOL: "", unitsOfLength: "" } - }, { multi: true }); + }, { bypassCollection2: true, multi: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/1_rebuild_account_and_order_search_collections.js b/imports/plugins/core/versions/server/migrations/1_rebuild_account_and_order_search_collections.js index c91ad27a4c5..20761e188c4 100644 --- a/imports/plugins/core/versions/server/migrations/1_rebuild_account_and_order_search_collections.js +++ b/imports/plugins/core/versions/server/migrations/1_rebuild_account_and_order_search_collections.js @@ -1,21 +1,52 @@ import { Migrations } from "meteor/percolate:migrations"; import { OrderSearch, AccountSearch } from "/lib/collections"; -import { buildOrderSearch, buildAccountSearch } from "/imports/plugins/included/search-mongo/server/methods/searchcollections"; +import { Reaction, Logger } from "/server/api"; -Migrations.add({ +let buildOrderSearch; +let buildAccountSearch; + +async function loadSearchRecordBuilderIfItExists() { + const searchPackage = Reaction.getPackageSettings("reaction-search"); + + if (typeof searchPackage === "object") { + Logger.debug("Found stock search-mongo (reaction-search) plugin."); + + ({ + buildOrderSearch, + buildAccountSearch + } = await import("/imports/plugins/included/search-mongo/server/methods/searchcollections")); + } else { + Logger.warn("Failed to load reaction-search plugin. Skipping building order and account search records " + + "on version migration step 1."); + } +} + +loadSearchRecordBuilderIfItExists().then(() => Migrations.add({ version: 1, up() { OrderSearch.remove({}); AccountSearch.remove({}); - buildOrderSearch(); - buildAccountSearch(); + + if (buildOrderSearch) { + buildOrderSearch(); + } + + if (buildAccountSearch) { + buildAccountSearch(); + } }, down() { // whether we are going up or down we just want to update the search collections // to match whatever the current code in the build methods are. OrderSearch.remove({}); AccountSearch.remove({}); - buildOrderSearch(); - buildAccountSearch(); + + if (buildOrderSearch) { + buildOrderSearch(); + } + + if (buildAccountSearch) { + buildAccountSearch(); + } } -}); +}), (err) => Logger.warn(`Failed to run version migration step 1. Received error: ${err}.`)); diff --git a/imports/plugins/core/versions/server/migrations/20_add_shop_to_marketplace.js b/imports/plugins/core/versions/server/migrations/20_add_shop_to_marketplace.js new file mode 100644 index 00000000000..cd9ed78e432 --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/20_add_shop_to_marketplace.js @@ -0,0 +1,29 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Reaction } from "/server/api"; +import { Packages } from "/lib/collections"; + +// Migration file created for removing the admin role from shop manager group, and users in the group +Migrations.add({ + version: 20, + up() { + Packages.update({ + name: "reaction-marketplace", + shopId: Reaction.getPrimaryShopId() + }, { + $set: { + "settings.public.shopPrefix": "/shop" + } + }); + }, + + down() { + Packages.update({ + name: "reaction-marketplace", + shopId: Reaction.getPrimaryShopId() + }, { + $unset: { + "settings.public.shopPrefix": 1 + } + }); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/21_clean_cart_shipment_method.js b/imports/plugins/core/versions/server/migrations/21_clean_cart_shipment_method.js new file mode 100644 index 00000000000..0d5106c907b --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/21_clean_cart_shipment_method.js @@ -0,0 +1,25 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Cart } from "/lib/collections"; + +// aldeed:simple-schema behavior would lead to a dangling incomplete shipmentMethod +// on new carts, but NPM simpl-schema now complains about that when validating. +Migrations.add({ + version: 21, + up() { + Cart.find().forEach((cart) => { + const unset = {}; + + (cart.shipping || []).forEach((shippingItem, index) => { + const { shipmentMethod } = shippingItem; + if (!shipmentMethod) return; + if (Object.keys(shipmentMethod).length === 1) { + unset[`shipping.${index}.shipmentMethod`] = 1; + } + }); + + if (Object.keys(unset).length > 0) { + Cart.update({ _id: cart._id }, { $unset: unset }); + } + }); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/22_register_verify_account.js b/imports/plugins/core/versions/server/migrations/22_register_verify_account.js new file mode 100644 index 00000000000..1ab239871fd --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/22_register_verify_account.js @@ -0,0 +1,35 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Packages } from "/lib/collections"; + + +Migrations.add({ + version: 22, + up() { + const pkg = Packages.findOne({ name: "reaction-accounts" }); + for (const route of pkg.registry) { + if (route.route === "/account/profile/verify:email?") { + route.route = "/account/profile/verify"; + route.template = "VerifyAccount"; + Packages.update( + { _id: pkg._id }, + { $set: { registry: pkg.registry } } + ); + break; + } + } + }, + down() { + const pkg = Packages.findOne({ name: "reaction-accounts" }); + for (const route of pkg.registry) { + if (route.route === "/account/profile/verify") { + route.route = "/account/profile/verify:email?"; + route.template = "verifyAccount"; + Packages.update( + { _id: pkg._id }, + { $set: { registry: pkg.registry } } + ); + break; + } + } + } +}); diff --git a/imports/plugins/core/versions/server/migrations/23_drop_tempstore_collections.js b/imports/plugins/core/versions/server/migrations/23_drop_tempstore_collections.js new file mode 100644 index 00000000000..3dbf26609ba --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/23_drop_tempstore_collections.js @@ -0,0 +1,18 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { MongoInternals } from "meteor/mongo"; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +Migrations.add({ + version: 23, + up() { + try { + Promise.await(db.dropCollection("cfs._tempstore.chunks")); + Promise.await(db.dropCollection("cfs_gridfs._tempstore.chunks")); + Promise.await(db.dropCollection("cfs_gridfs._tempstore.files")); + } catch (error) { + // These seem to throw an error from mongo NPM pkg, but only after + // they succeed in dropping the collections, so we'll just ignore + } + } +}); 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 new file mode 100644 index 00000000000..853038f922e --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/24_publish_all_existing_visible_products.js @@ -0,0 +1,14 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Products } from "/lib/collections"; +import { publishProductsToCatalog } from "/imports/plugins/core/catalog/server/methods/catalog"; + +Migrations.add({ + version: 24, + up() { + const visiblePublishedProducts = Products.find( + { isVisible: true, isDeleted: false, type: "simple" }, + { _id: 1 } + ).map((product) => product._id); + publishProductsToCatalog(visiblePublishedProducts); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/2_add_key_to_search_ui.js b/imports/plugins/core/versions/server/migrations/2_add_key_to_search_ui.js index bd0ad5405fe..26f352e4c9a 100644 --- a/imports/plugins/core/versions/server/migrations/2_add_key_to_search_ui.js +++ b/imports/plugins/core/versions/server/migrations/2_add_key_to_search_ui.js @@ -6,19 +6,21 @@ Migrations.add({ version: 2, up() { if (Packages) { - Packages.update({ - name: "reaction-ui-search" - }, { - $set: { - registry: [ - { - name: "Search Modal", - provides: ["ui-search"], - template: "searchModal" - } - ] - } - }, { multi: true }); + Packages.update( + { name: "reaction-ui-search" }, + { + $set: { + registry: [ + { + name: "Search Modal", + provides: ["ui-search"], + template: "searchModal" + } + ] + } + }, + { bypassCollection2: true, multi: true } + ); } } }); diff --git a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js index 31cc68ddc6a..4fc400360d1 100644 --- a/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js +++ b/imports/plugins/core/versions/server/migrations/3_reset_package_registry.js @@ -11,9 +11,9 @@ Migrations.add({ registry: [] } }, - { multi: true } + { bypassCollection2: true, multi: true } ); Reaction.loadPackages(); - Reaction.Import.flush(); + Reaction.Importer.flush(); } }); diff --git a/imports/plugins/core/versions/server/migrations/4_update_templates_priority.js b/imports/plugins/core/versions/server/migrations/4_update_templates_priority.js index 98b3a974ffd..7db38e05c2c 100644 --- a/imports/plugins/core/versions/server/migrations/4_update_templates_priority.js +++ b/imports/plugins/core/versions/server/migrations/4_update_templates_priority.js @@ -41,7 +41,7 @@ function updateHandler(oldValue, newValue) { if (changed) { Packages.update(pkg._id, { $set: { layout: pkg.layout } - }); + }, { bypassCollection2: true }); } }; } diff --git a/imports/plugins/core/versions/server/migrations/5_update_defaultRoles_to_groups.js b/imports/plugins/core/versions/server/migrations/5_update_defaultRoles_to_groups.js index bf407a63cbc..54e28dc0011 100644 --- a/imports/plugins/core/versions/server/migrations/5_update_defaultRoles_to_groups.js +++ b/imports/plugins/core/versions/server/migrations/5_update_defaultRoles_to_groups.js @@ -18,7 +18,7 @@ Migrations.add({ // needed to ensure restart in case of a migration that failed before finishing Groups.remove({}); - Accounts.update({}, { $set: { groups: [] } }, { multi: true }); + Accounts.update({}, { $set: { groups: [] } }, { bypassCollection2: true, multi: true }); if (shops && shops.length) { shops.forEach((shop) => { @@ -38,7 +38,7 @@ Migrations.add({ slug: `custom${index + 1}`, permissions, shopId: shop._id - }); + }, { bypassCollection2: true }); updateAccountsInGroup({ shopId: shop._id, permissions, @@ -95,7 +95,7 @@ Migrations.add({ Accounts.update( { _id: { $in: matchingUserIds }, shopId }, { $addToSet: { groups: groupId } }, - { multi: true } + { bypassCollection2: true, multi: true } ); return matchingUserIds; } @@ -117,7 +117,7 @@ Migrations.add({ shopGroups.forEach((group) => { const shopkey = keys[group.slug]; Shops.update({ _id: shop._id }, { $set: { [shopkey]: group.permissions } }); - Accounts.update({ shopId: shop._id }, { $unset: { groups: "" } }, { multi: true }); + Accounts.update({ shopId: shop._id }, { $unset: { groups: "" } }, { bypassCollection2: true, multi: true }); }); } } diff --git a/imports/plugins/core/versions/server/migrations/6_update_tags_is_visible.js b/imports/plugins/core/versions/server/migrations/6_update_tags_is_visible.js index 72a78aeef3f..2d65f88cac3 100644 --- a/imports/plugins/core/versions/server/migrations/6_update_tags_is_visible.js +++ b/imports/plugins/core/versions/server/migrations/6_update_tags_is_visible.js @@ -8,13 +8,13 @@ Migrations.add({ $set: { isVisible: true } - }, { multi: true }); + }, { bypassCollection2: true, multi: true }); }, down() { Tags.update({}, { $unset: { isVisible: null } - }, { multi: true }); + }, { bypassCollection2: true, multi: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/7_add_shop_slugs_to_schema.js b/imports/plugins/core/versions/server/migrations/7_add_shop_slugs_to_schema.js index 2a7c03de792..d1d11a2ff09 100644 --- a/imports/plugins/core/versions/server/migrations/7_add_shop_slugs_to_schema.js +++ b/imports/plugins/core/versions/server/migrations/7_add_shop_slugs_to_schema.js @@ -23,7 +23,7 @@ Migrations.add({ $set: { slug: shopSlug } - }); + }, { bypassCollection2: true }); } // if the shop is a merchant shop, create an obeject for it to - these will be used to create shop routes @@ -42,7 +42,7 @@ Migrations.add({ $set: { merchantShops } - }); + }, { bypassCollection2: true }); } }, @@ -53,6 +53,6 @@ Migrations.add({ slug: null, merchantShops: null } - }, { multi: true }); + }, { bypassCollection2: true, multi: true }); } }); diff --git a/imports/plugins/core/versions/server/migrations/8_update_registry_provides_to_array.js b/imports/plugins/core/versions/server/migrations/8_update_registry_provides_to_array.js index bcb548fb7e6..922ec3b31ab 100644 --- a/imports/plugins/core/versions/server/migrations/8_update_registry_provides_to_array.js +++ b/imports/plugins/core/versions/server/migrations/8_update_registry_provides_to_array.js @@ -22,7 +22,7 @@ Migrations.add({ $set: { registry: updatedRegistry } - }); + }, { bypassCollection2: true }); } }); }, @@ -49,7 +49,7 @@ Migrations.add({ $set: { registry: updatedRegistry } - }); + }, { bypassCollection2: true }); } }); } diff --git a/imports/plugins/core/versions/server/migrations/9_update_metrics.js b/imports/plugins/core/versions/server/migrations/9_update_metrics.js index bc95d2c0181..e0e642377e4 100644 --- a/imports/plugins/core/versions/server/migrations/9_update_metrics.js +++ b/imports/plugins/core/versions/server/migrations/9_update_metrics.js @@ -37,7 +37,7 @@ Migrations.add({ baseUOM: shop.baseUOM, unitsOfMeasure: shop.unitsOfMeasure } - }); + }, { bypassCollection2: true }); }); }, down() { @@ -47,7 +47,7 @@ Migrations.add({ $set: { baseUOM: shop.baseUOM } - }); + }, { bypassCollection2: true }); }); } }); diff --git a/imports/plugins/core/versions/server/migrations/index.js b/imports/plugins/core/versions/server/migrations/index.js index c8a370662ff..26658655eb5 100644 --- a/imports/plugins/core/versions/server/migrations/index.js +++ b/imports/plugins/core/versions/server/migrations/index.js @@ -17,3 +17,8 @@ import "./16_update_billing_paymentMethod"; import "./17_set_shop_uols"; import "./18_use_react_for_header_and_footer_layout"; import "./19_remove_admin_from_shop_manager"; +import "./20_add_shop_to_marketplace"; +import "./21_clean_cart_shipment_method"; +import "./22_register_verify_account"; +import "./23_drop_tempstore_collections"; +import "./24_publish_all_existing_visible_products"; diff --git a/imports/plugins/included/connectors-shopify/lib/collections/schemas/shopifyConnect.js b/imports/plugins/included/connectors-shopify/lib/collections/schemas/shopifyConnect.js index d26a2e0cc04..d551470be84 100644 --- a/imports/plugins/included/connectors-shopify/lib/collections/schemas/shopifyConnect.js +++ b/imports/plugins/included/connectors-shopify/lib/collections/schemas/shopifyConnect.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "/imports/plugins/core/collections"; @@ -19,35 +21,37 @@ import { registerSchema } from "/imports/plugins/core/collections"; * @property {String} description Shopify webhook description, currently unused */ const Webhook = new SimpleSchema({ - shopifyId: { - type: Number, - label: "Shopify webhook ID", - decimal: false + "shopifyId": { + type: SimpleSchema.Integer, + label: "Shopify webhook ID" }, - topic: { + "topic": { type: String, label: "Shopify webhook topic" }, - address: { + "address": { type: String, label: "URL webhook will POST to" }, - format: { + "format": { type: String, label: "Format of webhook data" }, - integrations: { - type: [String], + "integrations": { + type: Array, label: "Integrations currently using this webhook", optional: true }, + "integrations.$": { + type: String + }, // Currently unused, might want it later - description: { + "description": { type: String, label: "Shopify webhook description", optional: true } -}); +}, { check, tracker: Tracker }); registerSchema("Webhook", Webhook); @@ -72,7 +76,7 @@ export const Synchook = new SimpleSchema({ type: String, label: "Exactly what to sync in this topic" } -}); +}, { check, tracker: Tracker }); registerSchema("Synchook", Synchook); @@ -85,39 +89,50 @@ registerSchema("Synchook", Synchook); * @property {String} settings.shopName Shop slug * @property {Array} settings.webhooks Array of registered Shopify webhooks */ -export const ShopifyConnectPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.apiKey": { - type: String, - label: "API key", - optional: true - }, - "settings.password": { - type: String, - label: "API password", - optional: true - }, - "settings.sharedSecret": { - type: String, - label: "API shared secret", - optional: true - }, - "settings.shopName": { - type: String, - label: "Shop slug", - optional: true - }, - "settings.webhooks": { - type: [Webhook], - label: "Registered Shopify webhooks", - optional: true - }, - "settings.synchooks": { - type: [Synchook], - label: "Hooks being used to sync outbound with Shopify", - optional: true - } +export const ShopifyConnectPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.apiKey": { + type: String, + label: "API key", + optional: true + }, + "settings.password": { + type: String, + label: "API password", + optional: true + }, + "settings.sharedSecret": { + type: String, + label: "API shared secret", + optional: true + }, + "settings.shopName": { + type: String, + label: "Shop slug", + optional: true + }, + "settings.synchooks": { + type: Array, + label: "Hooks being used to sync outbound with Shopify", + optional: true + }, + "settings.synchooks.$": { + type: Synchook + }, + "settings.webhooks": { + type: Array, + label: "Registered Shopify webhooks", + optional: true + }, + "settings.webhooks.$": { + type: Webhook } -]); +}); registerSchema("ShopifyConnectPackageConfig", ShopifyConnectPackageConfig); diff --git a/imports/plugins/included/connectors-shopify/server/collections/shopifyCustomer.js b/imports/plugins/included/connectors-shopify/server/collections/shopifyCustomer.js index 917ba621f8c..722909674f1 100644 --- a/imports/plugins/included/connectors-shopify/server/collections/shopifyCustomer.js +++ b/imports/plugins/included/connectors-shopify/server/collections/shopifyCustomer.js @@ -1,5 +1,5 @@ /* eslint camelcase: 0 */ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; import { Accounts } from "/lib/collections"; import { registerSchema } from "/imports/plugins/core/collections"; @@ -17,14 +17,12 @@ import { registerSchema } from "/imports/plugins/core/collections"; */ export const ShopifyCustomer = new SimpleSchema({ shopifyId: { - type: Number, - optional: true, - decimal: false + type: SimpleSchema.Integer, + optional: true }, orders_count: { - type: Number, - optional: true, - decimal: false + type: SimpleSchema.Integer, + optional: true }, tags: { type: String, diff --git a/imports/plugins/included/connectors-shopify/server/collections/shopifyProduct.js b/imports/plugins/included/connectors-shopify/server/collections/shopifyProduct.js index c8d306d59e4..9f60c79229d 100644 --- a/imports/plugins/included/connectors-shopify/server/collections/shopifyProduct.js +++ b/imports/plugins/included/connectors-shopify/server/collections/shopifyProduct.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { Products } from "/lib/collections"; import { registerSchema } from "/imports/plugins/core/collections"; @@ -16,11 +18,10 @@ import { registerSchema } from "/imports/plugins/core/collections"; */ export const ShopifyProduct = new SimpleSchema({ shopifyId: { - type: Number, - optional: true, - decimal: false + type: SimpleSchema.Integer, + optional: true } -}); +}, { check, tracker: Tracker }); registerSchema("ShopifyProduct", ShopifyProduct); diff --git a/imports/plugins/included/connectors-shopify/server/endpoints/webhooks.js b/imports/plugins/included/connectors-shopify/server/endpoints/webhooks.js index e5ded2c7c34..54fa9e6fa70 100644 --- a/imports/plugins/included/connectors-shopify/server/endpoints/webhooks.js +++ b/imports/plugins/included/connectors-shopify/server/endpoints/webhooks.js @@ -1,6 +1,7 @@ import crypto from "crypto"; import { Meteor } from "meteor/meteor"; import { Reaction } from "/server/api"; +import { methods } from "../methods/sync/orders"; /** * verifies that the request coming from a webhook is from the connected Shopify shop @@ -48,6 +49,6 @@ Reaction.Endpoints.add("post", "/webhooks/shopify/orders-create", (req, res) => // If we can verify that this request is legitimate, call our shopify/sync/orders/created if (verifyWebhook(req)) { - Meteor.call("connectors/shopify/sync/orders/created", req.body.line_items); + methods.adjustInventory(req.body.line_items); } }); diff --git a/imports/plugins/included/connectors-shopify/server/jobs/image-import.js b/imports/plugins/included/connectors-shopify/server/jobs/image-import.js index 90aaca5d56a..0c84c216cd6 100644 --- a/imports/plugins/included/connectors-shopify/server/jobs/image-import.js +++ b/imports/plugins/included/connectors-shopify/server/jobs/image-import.js @@ -1,19 +1,26 @@ -import { Jobs, Media } from "/lib/collections"; +import { FileRecord } from "@reactioncommerce/file-collections"; +import { Jobs } from "/lib/collections"; +import { Media } from "/imports/plugins/core/files/server"; +import fetch from "node-fetch"; + +async function addMediaFromUrl({ url, metadata }) { + const fileRecord = await FileRecord.fromUrl(url, { fetch }); + + // Set workflow to "published" to bypass revision control on insert for this image. + fileRecord.metadata = { ...metadata, workflow: "published" }; + + return Media.insert(fileRecord); +} export const importImages = () => { Jobs.processJobs("connectors/shopify/import/image", { pollInterval: 60 * 60 * 1000, // Retry failed images after an hour workTimeout: 5 * 1000 // No image import should last more than 5 seconds }, (job, callback) => { - const { url, metadata } = job.data; + const { data } = job; + const { url } = data; try { - const fileObj = new FS.File(); - fileObj.attachData(url); - - // Set workflow to "published" to bypass revision control on insert for this image. - fileObj.metadata = { ...metadata, workflow: "published" }; - Media.insert(fileObj); - // Logger.info(`Image inserted from ${url} into ${metadata}`); + Promise.await(addMediaFromUrl(data)); job.done(`Finished importing image from ${url}`); callback(); } catch (error) { diff --git a/imports/plugins/included/connectors-shopify/server/methods/import/customers.js b/imports/plugins/included/connectors-shopify/server/methods/import/customers.js index 4579ea0e953..27acfc560b4 100644 --- a/imports/plugins/included/connectors-shopify/server/methods/import/customers.js +++ b/imports/plugins/included/connectors-shopify/server/methods/import/customers.js @@ -170,7 +170,10 @@ export const methods = { ids.push(reactionCustomerId); Accounts.update({ _id: reactionCustomerId }, { publish: true }); - Hooks.Events.run("afterAccountsUpdate", Meteor.userId(), reactionCustomerId); + Hooks.Events.run("afterAccountsUpdate", Meteor.userId(), { + accountId: reactionCustomerId, + updatedFields: ["forceIndex"] + }); } else { // customer already exists check Logger.info(`Customer ${shopifyCustomer.last_name} ${shopifyCustomer.id} already exists`); } diff --git a/imports/plugins/included/connectors-shopify/server/methods/import/products.js b/imports/plugins/included/connectors-shopify/server/methods/import/products.js index 93fa44d8077..8890221ad04 100644 --- a/imports/plugins/included/connectors-shopify/server/methods/import/products.js +++ b/imports/plugins/included/connectors-shopify/server/methods/import/products.js @@ -43,6 +43,7 @@ function createReactionProductFromShopifyProduct(options) { isBackorder: false, metafields: [], pageTitle: shopifyProduct.pageTitle, + price: { range: "0" }, productType: shopifyProduct.product_type, requiresShipping: true, shopId, // set shopId to active shopId; diff --git a/imports/plugins/included/connectors-shopify/server/methods/sync/orders.js b/imports/plugins/included/connectors-shopify/server/methods/sync/orders.js index 3e4829f7581..824c84432cb 100644 --- a/imports/plugins/included/connectors-shopify/server/methods/sync/orders.js +++ b/imports/plugins/included/connectors-shopify/server/methods/sync/orders.js @@ -1,8 +1,5 @@ -import { Meteor } from "meteor/meteor"; import { check } from "meteor/check"; -import { Reaction } from "/server/api"; import { Products } from "/lib/collections"; -import { connectorsRoles } from "../../lib/roles"; /** * @file Methods for syncing Shopify orders @@ -39,13 +36,9 @@ export const methods = { * @param {object} lineItems array of line items from a Shopify order * @returns {undefined} */ - "connectors/shopify/sync/orders/created": (lineItems) => { + adjustInventory: (lineItems) => { check(lineItems, [Object]); - if (!Reaction.hasPermission(connectorsRoles)) { - throw new Meteor.Error("access-denied", "Access Denied"); - } - lineItems.forEach((lineItem) => { const variantsWithShopifyId = Products.find({ shopifyId: lineItem.variant_id }).fetch(); @@ -62,12 +55,11 @@ export const methods = { eventLog: { title: "Product inventory updated by Shopify webhook", type: "update-webhook", - description: `Shopify order created which caused inventory to be reduced by ${lineItem.quantity}` + description: `Shopify order created which caused inventory to be reduced by ${lineItem.quantity}`, + createdAt: new Date() } } - }, { selector: { type: "variant" } }); + }, { selector: { type: "variant" }, publish: true }); }); } }; - -Meteor.methods(methods); diff --git a/imports/plugins/included/default-theme/client/styles/calendarPicker.less b/imports/plugins/included/default-theme/client/styles/calendarPicker.less index 5fb57d9f6b0..081ea2b3058 100644 --- a/imports/plugins/included/default-theme/client/styles/calendarPicker.less +++ b/imports/plugins/included/default-theme/client/styles/calendarPicker.less @@ -96,7 +96,12 @@ color: @rui-default-text; background: #f7f7f7; - button { + .CalendarDay__contents { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; background: @rui-info; border-radius: 50%; } diff --git a/imports/plugins/included/default-theme/client/styles/cart/cartSubTotals.less b/imports/plugins/included/default-theme/client/styles/cart/cartSubTotals.less index 59f55a2f16b..df5ffdec7f4 100644 --- a/imports/plugins/included/default-theme/client/styles/cart/cartSubTotals.less +++ b/imports/plugins/included/default-theme/client/styles/cart/cartSubTotals.less @@ -1,20 +1,21 @@ -.cart-drawer { - .cart-items { - // border: 3px solid @body-bg - } - +.cart-items { + .table { background-color: transparent; } - + + .table.loading { + opacity: 0.5; + } + .cart-totals { .text-align(left); - padding: 15px; - font-size: 16px; - height: 100%; - width: 225px; - height: 225px; - color: contrast(@cart-drawer-bg, black, white); + + .spinner-container { + position: absolute; + width: 100%; + } + } thead { diff --git a/imports/plugins/included/default-theme/client/styles/cart/checkout.less b/imports/plugins/included/default-theme/client/styles/cart/checkout.less index 0b21ebca173..33951201545 100644 --- a/imports/plugins/included/default-theme/client/styles/cart/checkout.less +++ b/imports/plugins/included/default-theme/client/styles/cart/checkout.less @@ -388,5 +388,7 @@ width: 162px; font-weight: 600; border: 0; + margin-top: 10px; + vertical-align: top; } } diff --git a/imports/plugins/included/default-theme/client/styles/dashboard/accounts.less b/imports/plugins/included/default-theme/client/styles/dashboard/accounts.less index 14570a453da..a6cfc59582b 100644 --- a/imports/plugins/included/default-theme/client/styles/dashboard/accounts.less +++ b/imports/plugins/included/default-theme/client/styles/dashboard/accounts.less @@ -168,3 +168,13 @@ text-transform: capitalize; } } + +.verify-account .fa { + font-size: 8rem; + &.fa-times-circle-o { + color: #f33; + }; + &.fa-check-circle-o { + color: #49da49; + }; +} diff --git a/imports/plugins/included/default-theme/client/styles/media.less b/imports/plugins/included/default-theme/client/styles/media.less index c04ed01f90e..50652a4735f 100644 --- a/imports/plugins/included/default-theme/client/styles/media.less +++ b/imports/plugins/included/default-theme/client/styles/media.less @@ -56,14 +56,14 @@ // Gallery Thumbnails -.rui.media-gallery .rui.gallery-thumbnails { +.rui.gallery-thumbnails { display: flex; flex-wrap: wrap; align-items: center; // justify-content: center; } -.rui.media-gallery .rui.gallery-thumbnails .gallery-image { +.rui.gallery-thumbnails .gallery-image { position: relative; flex-basis: 25%; padding: 3px; @@ -76,10 +76,10 @@ height: auto; } -.rui.media-gallery .rui.badge-container { +.gallery-image .rui.badge-container { position: absolute; - top: @badge-offset + 5px; - right: @badge-offset + 5px; + top: 6px; + right: 6px; z-index: 1; .rui.status-badge { @@ -190,4 +190,4 @@ right: 100% !important; margin-right: 10px; } -} \ No newline at end of file +} diff --git a/imports/plugins/included/discount-codes/client/templates/settings.html b/imports/plugins/included/discount-codes/client/templates/settings.html index c437d9e8025..70163bac895 100644 --- a/imports/plugins/included/discount-codes/client/templates/settings.html +++ b/imports/plugins/included/discount-codes/client/templates/settings.html @@ -15,7 +15,7 @@ {{#autoForm schema=discountSchema type="method-update" - meteormethod="discounts/addCode" + meteormethod="discounts/editCode" doc=discountCode id="discount-codes-update-form" resetOnSuccess=true diff --git a/imports/plugins/included/discount-codes/lib/collections/schemas/codes.js b/imports/plugins/included/discount-codes/lib/collections/schemas/codes.js index 807b70cf283..7f90312d573 100644 --- a/imports/plugins/included/discount-codes/lib/collections/schemas/codes.js +++ b/imports/plugins/included/discount-codes/lib/collections/schemas/codes.js @@ -1,4 +1,4 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; import { Discounts } from "/imports/plugins/core/discounts/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -8,34 +8,32 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * @desc schema that extends discount schema * with properties for discount codes. */ -export const DiscountCodes = new SimpleSchema([ - Discounts, { - "discountMethod": { - label: "Method", - type: String, - defaultValue: "code" - }, - "calculation.method": { - type: String, - index: 1, - defaultValue: "discount" - }, - "code": { - label: "Discount Code", - type: String - }, - "conditions.redemptionLimit": { - type: Number, - label: "Total Limit", - optional: true - }, - "conditions.accountLimit": { - type: Number, - label: "Account Limit", - defaultValue: 1, - optional: true - } +export const DiscountCodes = Discounts.clone().extend({ + "discountMethod": { + label: "Method", + type: String, + defaultValue: "code" + }, + "calculation.method": { + type: String, + index: 1, + defaultValue: "discount" + }, + "code": { + label: "Discount Code", + type: String + }, + "conditions.redemptionLimit": { + type: SimpleSchema.Integer, + label: "Total Limit", + optional: true + }, + "conditions.accountLimit": { + type: SimpleSchema.Integer, + label: "Account Limit", + defaultValue: 1, + optional: true } -]); +}); registerSchema("DiscountCodes", DiscountCodes); diff --git a/imports/plugins/included/discount-codes/lib/collections/schemas/config.js b/imports/plugins/included/discount-codes/lib/collections/schemas/config.js index 53fffd9252b..06396c0e562 100644 --- a/imports/plugins/included/discount-codes/lib/collections/schemas/config.js +++ b/imports/plugins/included/discount-codes/lib/collections/schemas/config.js @@ -1,4 +1,3 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { DiscountsPackageConfig } from "/imports/plugins/core/discounts/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -8,18 +7,17 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * @desc schema that extends discount schema * with properties for discount rates. */ -export const DiscountCodesPackageConfig = new SimpleSchema([ - DiscountsPackageConfig, { - "settings.codes": { - type: Object, - optional: true - }, - "settings.codes.enabled": { - type: Boolean, - optional: true, - defaultValue: false - } +export const DiscountCodesPackageConfig = DiscountsPackageConfig.clone().extend({ + "settings.codes": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.codes.enabled": { + type: Boolean, + optional: true, + defaultValue: false } -]); +}); registerSchema("DiscountCodesPackageConfig", DiscountCodesPackageConfig); diff --git a/imports/plugins/included/discount-codes/server/methods/methods.app-test.js b/imports/plugins/included/discount-codes/server/methods/methods.app-test.js index 320c38e6c07..c47a178eb8e 100644 --- a/imports/plugins/included/discount-codes/server/methods/methods.app-test.js +++ b/imports/plugins/included/discount-codes/server/methods/methods.app-test.js @@ -17,8 +17,7 @@ const code = { }; before(function () { - this.timeout(10000); - Meteor._sleepForMs(7000); + this.timeout(15000); }); describe("discount code methods", function () { @@ -33,23 +32,21 @@ describe("discount code methods", function () { }); describe("discounts/addCode", function () { - it("should throw 403 error with discounts permission", function (done) { + it("should throw 403 error with discounts permission", function () { sandbox.stub(Roles, "userIsInRole", () => false); // this should actually trigger a whole lot of things expect(() => Meteor.call("discounts/addCode", code)).to.throw(Meteor.Error, /Access Denied/); - return done(); }); + // admin user - it("should add code when user has role", function (done) { + it("should add code when user has role", function () { sandbox.stub(Roles, "userIsInRole", () => true); const discountInsertSpy = sandbox.spy(Discounts, "insert"); const discountId = Meteor.call("discounts/addCode", code); expect(discountInsertSpy).to.have.been.called; - Meteor._sleepForMs(500); const discountCount = Discounts.find(discountId).count(); expect(discountCount).to.equal(1); - return done(); }); }); diff --git a/imports/plugins/included/discount-codes/server/methods/methods.js b/imports/plugins/included/discount-codes/server/methods/methods.js index a9f1b5a8df7..b6e931bfb82 100644 --- a/imports/plugins/included/discount-codes/server/methods/methods.js +++ b/imports/plugins/included/discount-codes/server/methods/methods.js @@ -101,28 +101,41 @@ export const methods = { return discount; }, + /** - * discounts/addCode - * @param {String} modifier update statement - * @param {String} docId discount docId - * @param {String} qty create this many additional codes - * @return {String} returns update/insert result + * @name discounts/addCode + * @method + * @param {Object} doc A Discounts document to be inserted + * @param {String} [docId] DEPRECATED. Existing ID to trigger an update. Use discounts/editCode method instead. + * @return {String} Insert result */ - "discounts/addCode"(modifier, docId) { - check(modifier, Object); - check(docId, Match.OneOf(String, null, undefined)); + "discounts/addCode"(doc, docId) { + check(doc, Object); // actual schema validation happens during insert below - // check permissions to add - if (!Reaction.hasPermission("discount-codes")) { - throw new Meteor.Error("access-denied", "Access Denied"); - } - // if no doc, insert - if (!docId) { - return Discounts.insert(modifier); - } - // else update and return - return Discounts.update(docId, modifier); + // Backward compatibility + check(docId, Match.Optional(String)); + if (docId) return Meteor.call("discounts/editCode", { _id: docId, modifier: doc }); + + if (!Reaction.hasPermission("discount-codes")) throw new Meteor.Error("access-denied", "Access Denied"); + return Discounts.insert(doc); }, + + /** + * @name discounts/editCode + * @method + * @param {Object} details An object with _id and modifier props + * @return {String} Update result + */ + "discounts/editCode"(details) { + check(details, { + _id: String, + modifier: Object // actual schema validation happens during update below + }); + if (!Reaction.hasPermission("discount-codes")) throw new Meteor.Error("access-denied", "Access Denied"); + const { _id, modifier } = details; + return Discounts.update(_id, modifier); + }, + /** * discounts/codes/remove * removes discounts that have been previously applied diff --git a/imports/plugins/included/discount-rates/client/settings/rates.html b/imports/plugins/included/discount-rates/client/settings/rates.html index 8df88d14c00..cf90889b106 100644 --- a/imports/plugins/included/discount-rates/client/settings/rates.html +++ b/imports/plugins/included/discount-rates/client/settings/rates.html @@ -14,7 +14,7 @@ {{#autoForm schema=discountRateSchema type="method-update" - meteormethod="discounts/addRate" + meteormethod="discounts/editRate" doc=discountRate id="discount-rates-update-form" resetOnSuccess=true diff --git a/imports/plugins/included/discount-rates/lib/collections/schemas/config.js b/imports/plugins/included/discount-rates/lib/collections/schemas/config.js index 187e487f9eb..65994ed18de 100644 --- a/imports/plugins/included/discount-rates/lib/collections/schemas/config.js +++ b/imports/plugins/included/discount-rates/lib/collections/schemas/config.js @@ -1,4 +1,3 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { DiscountsPackageConfig } from "/imports/plugins/core/discounts/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -8,18 +7,17 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * @desc schema that extends discount schema * with properties for discount rates. */ -export const DiscountRatesPackageConfig = new SimpleSchema([ - DiscountsPackageConfig, { - "settings.rates": { - type: Object, - optional: true - }, - "settings.rates.enabled": { - type: Boolean, - optional: true, - defaultValue: false - } +export const DiscountRatesPackageConfig = DiscountsPackageConfig.clone().extend({ + "settings.rates": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.rates.enabled": { + type: Boolean, + optional: true, + defaultValue: false } -]); +}); registerSchema("DiscountRatesPackageConfig", DiscountRatesPackageConfig); diff --git a/imports/plugins/included/discount-rates/lib/collections/schemas/rates.js b/imports/plugins/included/discount-rates/lib/collections/schemas/rates.js index 6b6aaea4e38..b3327ae38c8 100644 --- a/imports/plugins/included/discount-rates/lib/collections/schemas/rates.js +++ b/imports/plugins/included/discount-rates/lib/collections/schemas/rates.js @@ -1,4 +1,3 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { Discounts } from "/imports/plugins/core/discounts/lib/collections/schemas/discounts"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -8,14 +7,12 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * @desc schema that extends discount schema * with properties for discount codes. */ -export const DiscountRates = new SimpleSchema([ - Discounts, { - discountMethod: { - label: "Calculation Method", - type: String, - defaultValue: "rate" - } +export const DiscountRates = Discounts.clone().extend({ + discountMethod: { + label: "Calculation Method", + type: String, + defaultValue: "rate" } -]); +}); registerSchema("DiscountRates", DiscountRates); diff --git a/imports/plugins/included/discount-rates/server/methods/methods.app-test.js b/imports/plugins/included/discount-rates/server/methods/methods.app-test.js index f361832d3cc..5deed1c93b1 100644 --- a/imports/plugins/included/discount-rates/server/methods/methods.app-test.js +++ b/imports/plugins/included/discount-rates/server/methods/methods.app-test.js @@ -5,9 +5,15 @@ import { expect } from "meteor/practicalmeteor:chai"; import { sinon } from "meteor/practicalmeteor:sinon"; import { Discounts } from "/imports/plugins/core/discounts/lib/collections"; +const rate = { + discount: 12, + label: "Discount 5", + description: "Discount by 5%", + discountMethod: "rate" +}; + before(function () { - this.timeout(10000); - Meteor._sleepForMs(7000); + this.timeout(15000); }); describe("discount rate methods", function () { @@ -21,31 +27,22 @@ describe("discount rate methods", function () { sandbox.restore(); }); - const rate = { - discount: 12, - label: "Discount 5", - description: "Discount by 5%", - discountMethod: "rate" - }; - describe("discounts/addRate", function () { - it("should throw 403 error with discounts permission", function (done) { + it("should throw 403 error with discounts permission", function () { sandbox.stub(Roles, "userIsInRole", () => false); // this should actually trigger a whole lot of things expect(() => Meteor.call("discounts/addRate", rate)).to.throw(Meteor.Error, /Access Denied/); - return done(); }); + // admin user - it("should add rate when user has role", function (done) { + it("should add rate when user has role", function () { sandbox.stub(Roles, "userIsInRole", () => true); const discountInsertSpy = sandbox.spy(Discounts, "insert"); const discountId = Meteor.call("discounts/addRate", rate); expect(discountInsertSpy).to.have.been.called; const discountCount = Discounts.find(discountId).count(); - Meteor._sleepForMs(500); expect(discountCount).to.equal(1); - return done(); }); }); }); diff --git a/imports/plugins/included/discount-rates/server/methods/methods.js b/imports/plugins/included/discount-rates/server/methods/methods.js index a9d35f774a1..2035b9d86d2 100644 --- a/imports/plugins/included/discount-rates/server/methods/methods.js +++ b/imports/plugins/included/discount-rates/server/methods/methods.js @@ -26,6 +26,7 @@ export const methods = { // should be pricing rate lookup. return rate; }, + "discounts/rates/discount"(cartId, rateId) { check(cartId, String); check(rateId, String); @@ -33,26 +34,39 @@ export const methods = { // TODO: discounts/rates/discount return rate; }, + + /** + * @name discounts/addRate + * @method + * @param {Object} doc A Discounts document to be inserted + * @param {String} [docId] DEPRECATED. Existing ID to trigger an update. Use discounts/editCode method instead. + * @return {String} Insert result + */ + "discounts/addRate"(doc, docId) { + check(doc, Object); // actual schema validation happens during insert below + + // Backward compatibility + check(docId, Match.Optional(String)); + if (docId) return Meteor.call("discounts/editRate", { _id: docId, modifier: doc }); + + if (!Reaction.hasPermission("discount-rates")) throw new Meteor.Error("access-denied", "Access Denied"); + return Discounts.insert(doc); + }, + /** - * discounts/addRate - * @param {String} modifier update statement - * @param {String} docId discount docId - * @return {String} returns update/insert result + * @name discounts/editRate + * @method + * @param {Object} details An object with _id and modifier props + * @return {String} Update result */ - "discounts/addRate"(modifier, docId) { - check(modifier, Object); - check(docId, Match.OneOf(String, null, undefined)); - - // check permissions to add - if (!Reaction.hasPermission("discount-rates")) { - throw new Meteor.Error("access-denied", "Access Denied"); - } - // if no doc, insert - if (!docId) { - return Discounts.insert(modifier); - } - // else update and return - return Discounts.update(docId, modifier); + "discounts/editRate"(details) { + check(details, { + _id: String, + modifier: Object // actual schema validation happens during update below + }); + if (!Reaction.hasPermission("discount-rates")) throw new Meteor.Error("access-denied", "Access Denied"); + const { _id, modifier } = details; + return Discounts.update(_id, modifier); } }; diff --git a/imports/plugins/included/inventory/server/methods/inventory.js b/imports/plugins/included/inventory/server/methods/inventory.js index 766a9f56209..577013ff066 100644 --- a/imports/plugins/included/inventory/server/methods/inventory.js +++ b/imports/plugins/included/inventory/server/methods/inventory.js @@ -1,7 +1,6 @@ import { Meteor } from "meteor/meteor"; -import { check, Match } from "meteor/check"; import { Catalog } from "/lib/api"; -import { Inventory } from "/lib/collections"; +import { Inventory, Products } from "/lib/collections"; import { Logger, Reaction } from "/server/api"; /** @@ -13,19 +12,8 @@ import { Logger, Reaction } from "/server/api"; export function registerInventory(product) { // Retrieve schemas // TODO: Permit product type registration and iterate through product types and schemas - const simpleProductSchema = Reaction.collectionSchema("Products", { type: "simple" }); - const variantProductSchema = Reaction.collectionSchema("Products", { type: "variant" }); - check(product, Match.OneOf(simpleProductSchema, variantProductSchema)); - let type; - switch (product.type) { - case "variant": - check(product, variantProductSchema); - type = "variant"; - break; - default: - check(product, simpleProductSchema); - type = "simple"; - } + Products.simpleSchema(product).validate(product); + const { type } = product; let totalNewInventory = 0; const productId = type === "variant" ? product.ancestors[0] : product._id; @@ -84,21 +72,9 @@ export function registerInventory(product) { function adjustInventory(product, userId, context) { // TODO: This can fail even if updateVariant succeeds. - // Should probably look at making these two more atomic - const simpleProductSchema = Reaction.collectionSchema("Products", { type: "simple" }); - const variantProductSchema = Reaction.collectionSchema("Products", { type: "variant" }); - let type; + Products.simpleSchema(product).validate(product); + const { type } = product; let results; - // adds or updates inventory collection with this product - switch (product.type) { - case "variant": - check(product, variantProductSchema); - type = "variant"; - break; - default: - check(product, simpleProductSchema); - type = "simple"; - } // calledByServer is only true if this method was triggered by the server, such as from a webhook. // there will be a null connection and no userId. @@ -161,9 +137,7 @@ Meteor.methods({ registerInventory(product); }, "inventory/adjust"(product) { // TODO: this should be variant - const simpleProductSchema = Reaction.collectionSchema("Products", { type: "simple" }); - const variantProductSchema = Reaction.collectionSchema("Products", { type: "variant" }); - check(product, Match.OneOf(simpleProductSchema, variantProductSchema)); + Products.simpleSchema(product).validate(product); adjustInventory(product, this.userId, this); } }); diff --git a/imports/plugins/included/inventory/server/methods/statusChanges.js b/imports/plugins/included/inventory/server/methods/statusChanges.js index 6f16cf46d72..c9b9b988508 100644 --- a/imports/plugins/included/inventory/server/methods/statusChanges.js +++ b/imports/plugins/included/inventory/server/methods/statusChanges.js @@ -49,7 +49,7 @@ Meteor.methods({ * @return {Number} returns reservationCount */ "inventory/setStatus"(cartItems, status, currentStatus, notFoundStatus) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); check(status, Match.Optional(String)); check(currentStatus, Match.Optional(String)); check(notFoundStatus, Match.Optional(String)); @@ -159,7 +159,7 @@ Meteor.methods({ * @return {undefined} undefined */ "inventory/clearStatus"(cartItems, status, currentStatus) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); check(status, Match.Optional(String)); // workflow status check(currentStatus, Match.Optional(String)); this.unblock(); @@ -216,7 +216,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/clearReserve"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/clearStatus", cartItems); }, @@ -230,7 +230,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/addReserve"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/setStatus", cartItems); }, @@ -246,7 +246,7 @@ Meteor.methods({ * @returns {Number} number of inserted backorder documents */ "inventory/backorder"(reservation, backOrderQty) { - check(reservation, Schemas.Inventory); + Schemas.Inventory.validate(reservation); check(backOrderQty, Number); this.unblock(); @@ -308,7 +308,7 @@ Meteor.methods({ * @todo implement inventory/lowstock calculations */ "inventory/lowStock"(product) { - check(product, Schemas.Product); + Schemas.Product.validate(product); // placeholder is here to give plugins a place to hook into Logger.debug("inventory/lowStock"); }, @@ -322,7 +322,7 @@ Meteor.methods({ * @return {String} return remove result */ "inventory/remove"(inventoryItem) { - check(inventoryItem, Schemas.Inventory); + Schemas.Inventory.validate(inventoryItem); // user needs createProduct permission to adjust inventory // REVIEW: Should this be checking against shop permissions instead? @@ -349,7 +349,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/shipped"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/setStatus", cartItems, "shipped", "sold"); }, @@ -362,7 +362,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/sold"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/setStatus", cartItems, "sold", "reserved"); }, @@ -375,7 +375,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/return"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/setStatus", cartItems, "return"); }, @@ -388,7 +388,7 @@ Meteor.methods({ * @return {undefined} */ "inventory/returnToStock"(cartItems) { - check(cartItems, [Schemas.CartItem]); + Schemas.CartItem.validate(cartItems); return Meteor.call("inventory/clearStatus", cartItems, "new", "return"); } }); diff --git a/imports/plugins/included/marketplace/lib/collections/schemas/marketplace.js b/imports/plugins/included/marketplace/lib/collections/schemas/marketplace.js index 8f9a9178800..056b2e9d353 100644 --- a/imports/plugins/included/marketplace/lib/collections/schemas/marketplace.js +++ b/imports/plugins/included/marketplace/lib/collections/schemas/marketplace.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { Shop } from "/lib/collections/schemas/shops.js"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -12,125 +14,131 @@ export const ShopTypes = new SimpleSchema({ type: Boolean, defaultValue: false } -}); +}, { check, tracker: Tracker }); registerSchema("ShopTypes", ShopTypes); export const EnabledPackagesByShopType = new SimpleSchema({ - shopType: { - type: String - }, - enabledPackages: { - type: [String] - } -}); + shopType: String, + enabledPackages: [String] +}, { check, tracker: Tracker }); registerSchema("EnabledPackagesByShopType", EnabledPackagesByShopType); -export const MarketplacePackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.thirdPartyLogistics": { - type: Object, - blackbox: true, - optional: true - }, - "settings.shops.enabledShopTypes": { - type: [ShopTypes], - defaultValue: [{ - shopType: "merchant", - active: true - }, { - shopType: "affiliate", - active: false - }] - }, - "settings.shops.enabledPackagesByShopTypes": { - type: [EnabledPackagesByShopType], - optional: true - }, - "settings.payoutMethod": { - type: Object, - blackbox: true, - optional: true - }, - "settings.public": { - type: Object, - optional: true - }, - // if true, any user can create a shop - // if false, shop owners must be invited via Accounts panel - "settings.public.allowMerchantSignup": { - type: Boolean, - defaultValue: false - }, - // Deprecated - no longer used in any marketplace considerations - // marketplace is enabled and disabled via the package - // seller signup is controlled by the allowMerchantSignup setting - "settings.public.allowGuestSellers": { - type: Boolean, - defaultValue: false - }, - "settings.public.marketplaceNakedRoutes": { - type: Boolean, - defaultValue: true - }, - // if true, permit each merchant to setup their own payment provider - "settings.public.perMerchantPaymentProviders": { - type: Boolean, - defaultValue: false - }, - // if true, the cart should be different for each merchant - "settings.public.merchantCart": { - type: Boolean, - defaultValue: false - }, - // if true, each merchant sets their own currency - // TODO: REMOVE in favor of merchantLocale - // "settings.public.merchantCurrency": { - // type: Boolean, - // defaultValue: false - // }, - // if true, each merchant performs their own fulfillment - "settings.public.merchantFulfillment": { - type: Boolean, - defaultValue: true - }, - // if true, each merchant sets their own language - // // TODO: REMOVE in favor of merchantLocale - // "settings.public.merchantLanguage": { // DEPRECATED - // type: Boolean, - // defaultValue: false - // }, - // if true, each merchant sets their own locale, language, and currency - "settings.public.merchantLocale": { - type: Boolean, - defaultValue: false - }, - // if true, permit each merchant to setup their own shipping rates - "settings.public.merchantShippingRates": { - type: Boolean, - defaultValue: false - }, - // if true, each merchant sets their own currency - "settings.public.merchantTheme": { - type: Boolean, - defaultValue: false - } +export const MarketplacePackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.thirdPartyLogistics": { + type: Object, + blackbox: true, + optional: true + }, + "settings.shops.enabledShopTypes": { + type: Array, + defaultValue: [{ + shopType: "merchant", + active: true + }, { + shopType: "affiliate", + active: false + }] + }, + "settings.shops.enabledShopTypes.$": { + type: ShopTypes + }, + "settings.shops.enabledPackagesByShopTypes": { + type: Array, + optional: true + }, + "settings.shops.enabledPackagesByShopTypes.$": { + type: EnabledPackagesByShopType + }, + "settings.payoutMethod": { + type: Object, + blackbox: true, + optional: true + }, + "settings.public": { + type: Object, + optional: true, + defaultValue: {} + }, + // if true, any user can create a shop + // if false, shop owners must be invited via Accounts panel + "settings.public.allowMerchantSignup": { + type: Boolean, + defaultValue: false + }, + // Deprecated - no longer used in any marketplace considerations + // marketplace is enabled and disabled via the package + // seller signup is controlled by the allowMerchantSignup setting + "settings.public.allowGuestSellers": { + type: Boolean, + defaultValue: false + }, + "settings.public.marketplaceNakedRoutes": { + type: Boolean, + defaultValue: true + }, + // if true, permit each merchant to setup their own payment provider + "settings.public.perMerchantPaymentProviders": { + type: Boolean, + defaultValue: false + }, + // if true, the cart should be different for each merchant + "settings.public.merchantCart": { + type: Boolean, + defaultValue: false + }, + // if true, each merchant sets their own currency + // TODO: REMOVE in favor of merchantLocale + // "settings.public.merchantCurrency": { + // type: Boolean, + // defaultValue: false + // }, + // if true, each merchant performs their own fulfillment + "settings.public.merchantFulfillment": { + type: Boolean, + defaultValue: true + }, + // if true, each merchant sets their own language + // // TODO: REMOVE in favor of merchantLocale + // "settings.public.merchantLanguage": { // DEPRECATED + // type: Boolean, + // defaultValue: false + // }, + // if true, each merchant sets their own locale, language, and currency + "settings.public.merchantLocale": { + type: Boolean, + defaultValue: false + }, + // if true, permit each merchant to setup their own shipping rates + "settings.public.merchantShippingRates": { + type: Boolean, + defaultValue: false + }, + // if true, each merchant sets their own currency + "settings.public.merchantTheme": { + type: Boolean, + defaultValue: false } -]); +}); registerSchema("MarketplacePackageConfig", MarketplacePackageConfig); /** * Seller Shop Schema */ -export const SellerShop = new SimpleSchema([ - Shop, { - stripeConnectSettings: { - type: Object, - optional: true - } +export const SellerShop = Shop.clone().extend({ + stripeConnectSettings: { + type: Object, + optional: true } -]); +}); registerSchema("SellerShop", SellerShop); diff --git a/imports/plugins/included/marketplace/register.js b/imports/plugins/included/marketplace/register.js index 5e8edaac31f..a7083346af2 100644 --- a/imports/plugins/included/marketplace/register.js +++ b/imports/plugins/included/marketplace/register.js @@ -57,7 +57,8 @@ Reaction.registerPackage({ // merchantLanguage: false, // Language comes from active merchant shop // merchantCurrency: false, // Currency comes from active merchant shop merchantTheme: false, // Theme comes from active merchant shop - merchantShippingRates: false // Each merchant defines their own shipping rates + merchantShippingRates: false, // Each merchant defines their own shipping rates + shopPrefix: "/shop" // The prefix for the shop URL } }, registry: [{ diff --git a/imports/plugins/included/payments-authnet/lib/collections/schemas/authnet.js b/imports/plugins/included/payments-authnet/lib/collections/schemas/authnet.js index 6e6b3db045d..fd313b2d372 100644 --- a/imports/plugins/included/payments-authnet/lib/collections/schemas/authnet.js +++ b/imports/plugins/included/payments-authnet/lib/collections/schemas/authnet.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -11,32 +13,35 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * see: https://github.com/authnet/rest-api-sdk-nodejs */ -export const AuthNetPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.mode": { - type: Boolean, - defaultValue: false - }, - "settings.reaction-auth-net.support": { - type: Array, - label: "Payment provider supported methods" - }, - "settings.reaction-auth-net.support.$": { - type: String, - allowedValues: ["Authorize", "De-authorize", "Capture"] - }, - "settings.api_id": { - type: String, - label: "API Login ID", - min: 60 - }, - "settings.transaction_key": { - type: String, - label: "Transaction Key", - min: 60 - } +export const AuthNetPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.mode": { + type: Boolean, + defaultValue: false + }, + "settings.reaction-auth-net.support": { + type: Array, + label: "Payment provider supported methods" + }, + "settings.reaction-auth-net.support.$": { + type: String, + allowedValues: ["Authorize", "De-authorize", "Capture"] + }, + "settings.api_id": { + type: String, + label: "API Login ID" + }, + "settings.transaction_key": { + type: String, + label: "Transaction Key" } -]); +}); registerSchema("AuthNetPackageConfig", AuthNetPackageConfig); @@ -66,6 +71,6 @@ export const AuthNetPayment = new SimpleSchema({ label: "CVV", max: 4 } -}); +}, { check, tracker: Tracker }); registerSchema("AuthNetPayment", AuthNetPayment); diff --git a/imports/plugins/included/payments-authnet/server/methods/authnet.js b/imports/plugins/included/payments-authnet/server/methods/authnet.js index c5cc22e137e..c60e5f2b880 100644 --- a/imports/plugins/included/payments-authnet/server/methods/authnet.js +++ b/imports/plugins/included/payments-authnet/server/methods/authnet.js @@ -9,7 +9,8 @@ import { Promise } from "meteor/promise"; import AuthNetAPI from "authorize-net"; import { Reaction, Logger } from "/server/api"; import { Packages } from "/lib/collections"; -import { PaymentMethod } from "/lib/collections/schemas"; +import { ValidCardNumber, ValidExpireMonth, ValidExpireYear, ValidCVV } from "/lib/api"; +import { PaymentMethodArgument } from "/lib/collections/schemas"; function getAccountOptions(isPayment) { const queryConditions = { @@ -43,14 +44,6 @@ function getSettings(settings, ref, valueName) { return undefined; } -const ValidCardNumber = Match.Where((x) => /^[0-9]{14,16}$/.test(x)); - -const ValidExpireMonth = Match.Where((x) => /^[0-9]{1,2}$/.test(x)); - -const ValidExpireYear = Match.Where((x) => /^[0-9]{4}$/.test(x)); - -const ValidCVV = Match.Where((x) => /^[0-9]{3,4}$/.test(x)); - Meteor.methods({ authnetSubmit(transactionType = "authorizeTransaction", cardInfo, paymentInfo) { check(transactionType, String); @@ -104,7 +97,11 @@ Meteor.methods({ }, "authnet/payment/capture"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const { transactionId, amount @@ -168,8 +165,13 @@ Meteor.methods({ }, "authnet/refund/create"(paymentMethod, amount) { - check(paymentMethod, PaymentMethod); check(amount, Number); + + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const result = { saved: false, error: "Reaction does not yet support direct refund processing from Authorize.net. " + diff --git a/imports/plugins/included/payments-braintree/lib/collections/schemas/braintree.js b/imports/plugins/included/payments-braintree/lib/collections/schemas/braintree.js index 83a2d769980..c04002d588b 100644 --- a/imports/plugins/included/payments-braintree/lib/collections/schemas/braintree.js +++ b/imports/plugins/included/payments-braintree/lib/collections/schemas/braintree.js @@ -1,8 +1,9 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; - /** * Meteor.settings.braintree = * mode: false #sandbox @@ -12,38 +13,42 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * see: https://developers.braintreepayments.com/javascript+node/reference */ -export const BraintreePackageConfig = new SimpleSchema([ - PackageConfig, - { - "settings.mode": { - type: Boolean, - defaultValue: false - }, - "settings.merchant_id": { - type: String, - label: "Merchant ID", - optional: false - }, - "settings.public_key": { - type: String, - label: "Public Key", - optional: false - }, - "settings.private_key": { - type: String, - label: "Private Key", - optional: false - }, - "settings.reaction-braintree.support": { - type: Array, - label: "Payment provider supported methods" - }, - "settings.reaction-braintree.support.$": { - type: String, - allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] - } +export const BraintreePackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.mode": { + type: Boolean, + defaultValue: false + }, + "settings.merchant_id": { + type: String, + label: "Merchant ID", + optional: false + }, + "settings.public_key": { + type: String, + label: "Public Key", + optional: false + }, + "settings.private_key": { + type: String, + label: "Private Key", + optional: false + }, + "settings.reaction-braintree.support": { + type: Array, + label: "Payment provider supported methods" + }, + "settings.reaction-braintree.support.$": { + type: String, + allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] } -]); +}); registerSchema("BraintreePackageConfig", BraintreePackageConfig); @@ -73,6 +78,6 @@ export const BraintreePayment = new SimpleSchema({ max: 4, label: "CVV" } -}); +}, { check, tracker: Tracker }); registerSchema("BraintreePayment", BraintreePayment); diff --git a/imports/plugins/included/payments-braintree/server/methods/braintreeMethods.js b/imports/plugins/included/payments-braintree/server/methods/braintreeMethods.js index 5cf8e8dcf84..787023239ff 100644 --- a/imports/plugins/included/payments-braintree/server/methods/braintreeMethods.js +++ b/imports/plugins/included/payments-braintree/server/methods/braintreeMethods.js @@ -1,7 +1,7 @@ import { check } from "meteor/check"; import { BraintreeApi } from "./braintreeApi"; import { Logger } from "/server/api"; -import { PaymentMethod } from "/lib/collections/schemas"; +import { PaymentMethodArgument } from "/lib/collections/schemas"; /** * braintreeSubmit @@ -60,7 +60,10 @@ export function paymentSubmit(transactionType, cardData, paymentData) { * @return {Object} results - Object containing the results of the transaction */ export function paymentCapture(paymentMethod) { - check(paymentMethod, PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); const paymentCaptureDetails = { transactionId: paymentMethod.transactionId, @@ -95,9 +98,13 @@ export function paymentCapture(paymentMethod) { * @return {Object} results - Object containing the results of the transaction */ export function createRefund(paymentMethod, amount) { - check(paymentMethod, PaymentMethod); check(amount, Number); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const refundDetails = { transactionId: paymentMethod.transactionId, amount diff --git a/imports/plugins/included/payments-braintree/server/methods/braintreeapi-methods-refund.app-test.js b/imports/plugins/included/payments-braintree/server/methods/braintreeapi-methods-refund.app-test.js index 8d3e113e53d..e9025d572d0 100644 --- a/imports/plugins/included/payments-braintree/server/methods/braintreeapi-methods-refund.app-test.js +++ b/imports/plugins/included/payments-braintree/server/methods/braintreeapi-methods-refund.app-test.js @@ -57,14 +57,10 @@ describe("braintree/refund/create", function () { sandbox.stub(BraintreeApi.apiCall, "createRefund", function () { return braintreeRefundResult; }); - let refundResult = null; - let refundError = null; Meteor.call("braintree/refund/create", paymentMethod, paymentMethod.amount, function (error, result) { - refundResult = result; - refundError = error; - expect(refundError).to.be.undefined; - expect(refundResult).to.not.be.undefined; - expect(refundResult.saved).to.be.true; + expect(error).to.be.undefined; + expect(result).to.not.be.undefined; + expect(result.saved).to.be.true; return done(); }); }); diff --git a/imports/plugins/included/payments-example/lib/collections/schemas/example.js b/imports/plugins/included/payments-example/lib/collections/schemas/example.js index 5ce672d6e58..a90ea8d494a 100644 --- a/imports/plugins/included/payments-example/lib/collections/schemas/example.js +++ b/imports/plugins/included/payments-example/lib/collections/schemas/example.js @@ -1,20 +1,27 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; -export const ExamplePackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.mode": { - type: Boolean, - defaultValue: true - }, - "settings.apiKey": { - type: String, - label: "API Key", - optional: true - } +export const ExamplePackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.mode": { + type: Boolean, + defaultValue: true + }, + "settings.apiKey": { + type: String, + label: "API Key", + optional: true } -]); +}); registerSchema("ExamplePackageConfig", ExamplePackageConfig); @@ -44,6 +51,6 @@ export const ExamplePayment = new SimpleSchema({ max: 4, label: "CVV" } -}); +}, { check, tracker: Tracker }); registerSchema("ExamplePayment", ExamplePayment); diff --git a/imports/plugins/included/payments-example/server/methods/example.js b/imports/plugins/included/payments-example/server/methods/example.js index 749502e5ae1..004ee40a9d5 100644 --- a/imports/plugins/included/payments-example/server/methods/example.js +++ b/imports/plugins/included/payments-example/server/methods/example.js @@ -1,27 +1,12 @@ /* eslint camelcase: 0 */ // meteor modules import { Meteor } from "meteor/meteor"; -import { check, Match } from "meteor/check"; +import { check } from "meteor/check"; // reaction modules -import { Reaction, Logger } from "/server/api"; +import { Logger } from "/server/api"; +import { ValidCardNumber, ValidExpireMonth, ValidExpireYear, ValidCVV } from "/lib/api"; import { ExampleApi } from "./exampleapi"; - -function luhnValid(x) { - return [...x].reverse().reduce((sum, c, i) => { - let d = parseInt(c, 10); - if (i % 2 !== 0) { d *= 2; } - if (d > 9) { d -= 9; } - return sum + d; - }, 0) % 10 === 0; -} - -const ValidCardNumber = Match.Where((x) => /^[0-9]{13,16}$/.test(x) && luhnValid(x)); - -const ValidExpireMonth = Match.Where((x) => /^[0-9]{1,2}$/.test(x)); - -const ValidExpireYear = Match.Where((x) => /^[0-9]{4}$/.test(x)); - -const ValidCVV = Match.Where((x) => /^[0-9]{3,4}$/.test(x)); +import { PaymentMethodArgument } from "/lib/collections/schemas"; // function chargeObj() { // return { @@ -100,13 +85,16 @@ Meteor.methods({ /** * Capture a Charge - * @param {Object} paymentData Object containing data about the transaction to capture + * @param {Object} paymentMethod Object containing data about the transaction to capture * @return {Object} results normalized */ - "example/payment/capture"(paymentData) { - check(paymentData, Reaction.Schemas.PaymentMethod); - const authorizationId = paymentData.transactionId; - const { amount } = paymentData; + "example/payment/capture"(paymentMethod) { + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + + const { amount, transactionId: authorizationId } = paymentMethod; const response = ExampleApi.methods.capture.call({ authorizationId, amount @@ -125,8 +113,13 @@ Meteor.methods({ * @return {Object} result */ "example/refund/create"(paymentMethod, amount) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); check(amount, Number); + + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const { transactionId } = paymentMethod; const response = ExampleApi.methods.refund.call({ transactionId, @@ -145,7 +138,11 @@ Meteor.methods({ * @return {Object} result */ "example/refund/list"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const { transactionId } = paymentMethod; const response = ExampleApi.methods.refunds.call({ transactionId diff --git a/imports/plugins/included/payments-example/server/methods/exampleapi.js b/imports/plugins/included/payments-example/server/methods/exampleapi.js index c0462df3e82..b1a5801a37d 100644 --- a/imports/plugins/included/payments-example/server/methods/exampleapi.js +++ b/imports/plugins/included/payments-example/server/methods/exampleapi.js @@ -1,5 +1,5 @@ +import SimpleSchema from "simpl-schema"; import { ValidatedMethod } from "meteor/mdg:validated-method"; -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { Random } from "meteor/random"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -68,19 +68,19 @@ export const ExampleApi = {}; ExampleApi.methods = {}; export const cardSchema = new SimpleSchema({ - number: { type: String }, - name: { type: String }, - cvv2: { type: String }, - expireMonth: { type: String }, - expireYear: { type: String }, - type: { type: String } + number: String, + name: String, + cvv2: String, + expireMonth: String, + expireYear: String, + type: String }); registerSchema("cardSchema", cardSchema); export const paymentDataSchema = new SimpleSchema({ - total: { type: String }, - currency: { type: String } + total: String, + currency: String }); registerSchema("paymentDataSchema", paymentDataSchema); @@ -89,7 +89,7 @@ registerSchema("paymentDataSchema", paymentDataSchema); ExampleApi.methods.authorize = new ValidatedMethod({ name: "ExampleApi.methods.authorize", validate: new SimpleSchema({ - transactionType: { type: String }, + transactionType: String, cardData: { type: cardSchema }, paymentData: { type: paymentDataSchema } }).validator(), @@ -103,8 +103,8 @@ ExampleApi.methods.authorize = new ValidatedMethod({ ExampleApi.methods.capture = new ValidatedMethod({ name: "ExampleApi.methods.capture", validate: new SimpleSchema({ - authorizationId: { type: String }, - amount: { type: Number, decimal: true } + authorizationId: String, + amount: Number }).validator(), run(args) { const transactionId = args.authorizationId; @@ -118,8 +118,8 @@ ExampleApi.methods.capture = new ValidatedMethod({ ExampleApi.methods.refund = new ValidatedMethod({ name: "ExampleApi.methods.refund", validate: new SimpleSchema({ - transactionId: { type: String }, - amount: { type: Number, decimal: true } + transactionId: String, + amount: Number }).validator(), run(args) { const { transactionId, amount } = args.transactionId; @@ -132,7 +132,7 @@ ExampleApi.methods.refund = new ValidatedMethod({ ExampleApi.methods.refunds = new ValidatedMethod({ name: "ExampleApi.methods.refunds", validate: new SimpleSchema({ - transactionId: { type: String } + transactionId: String }).validator(), run(args) { const { transactionId } = args; diff --git a/imports/plugins/included/payments-paypal/lib/collections/schemas/paypal.js b/imports/plugins/included/payments-paypal/lib/collections/schemas/paypal.js index 217dc226a4f..43b70ea046e 100644 --- a/imports/plugins/included/payments-paypal/lib/collections/schemas/paypal.js +++ b/imports/plugins/included/payments-paypal/lib/collections/schemas/paypal.js @@ -1,76 +1,83 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; -export const PaypalPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.expressAuthAndCapture": { - type: Boolean, - label: "Capture at time of Auth", - defaultValue: false - }, - "settings.express.support": { - type: Array, - label: "Payment provider supported methods" - }, - "settings.express.support.$": { - type: String, - allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] - }, - "settings.payflow.support": { - type: Array, - label: "Payment provider supported methods" - }, - "settings.payflow.support.$": { - type: String, - allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] - }, - "settings.merchantId": { - type: String, - label: "Merchant ID", - optional: true - }, - "settings.username": { - type: String, - label: "Username", - optional: true - }, - "settings.password": { - type: String, - label: "Password", - optional: true - }, - "settings.signature": { - type: String, - label: "Signature", - optional: true - }, - "settings.express_mode": { - type: Boolean, - defaultValue: false - }, - "settings.payflow_enabled": { - type: Boolean, - defaultValue: true - }, - "settings.client_id": { - type: String, - label: "API Client ID", - min: 60, - optional: true - }, - "settings.client_secret": { - type: String, - label: "API Secret", - min: 60, - optional: true - }, - "settings.payflow_mode": { - type: Boolean, - defaultValue: false - } +export const PaypalPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.expressAuthAndCapture": { + type: Boolean, + label: "Capture at time of Auth", + defaultValue: false + }, + "settings.express.support": { + type: Array, + label: "Payment provider supported methods" + }, + "settings.express.support.$": { + type: String, + allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] + }, + "settings.payflow.support": { + type: Array, + label: "Payment provider supported methods" + }, + "settings.payflow.support.$": { + type: String, + allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] + }, + "settings.merchantId": { + type: String, + label: "Merchant ID", + optional: true + }, + "settings.username": { + type: String, + label: "Username", + optional: true + }, + "settings.password": { + type: String, + label: "Password", + optional: true + }, + "settings.signature": { + type: String, + label: "Signature", + optional: true + }, + "settings.express_mode": { + type: Boolean, + defaultValue: false + }, + "settings.payflow_enabled": { + type: Boolean, + defaultValue: true + }, + "settings.client_id": { + type: String, + label: "API Client ID", + min: 60, + optional: true + }, + "settings.client_secret": { + type: String, + label: "API Secret", + min: 60, + optional: true + }, + "settings.payflow_mode": { + type: Boolean, + defaultValue: false } -]); +}); registerSchema("PaypalPackageConfig", PaypalPackageConfig); @@ -100,6 +107,6 @@ export const PaypalPayment = new SimpleSchema({ max: 4, label: "CVV" } -}); +}, { check, tracker: Tracker }); registerSchema("PaypalPayment", PaypalPayment); diff --git a/imports/plugins/included/payments-paypal/server/methods/express.js b/imports/plugins/included/payments-paypal/server/methods/express.js index eaf501493c7..ef2f923c9c3 100644 --- a/imports/plugins/included/payments-paypal/server/methods/express.js +++ b/imports/plugins/included/payments-paypal/server/methods/express.js @@ -5,7 +5,8 @@ import { Meteor } from "meteor/meteor"; import { check } from "meteor/check"; import { PayPal } from "../../lib/api"; import { Shops, Cart, Packages } from "/lib/collections"; -import { Reaction, Logger } from "/server/api"; +import { Logger } from "/server/api"; +import { PaymentMethodArgument } from "/lib/collections/schemas"; let moment; async function lazyLoadMoment() { @@ -34,6 +35,9 @@ export const methods = { throw new Meteor.Error("invalid-parameter", "Bad shop ID"); } const amount = Number(cart.getTotal()); + const shippingAmt = Number(cart.getShippingTotal()); + const taxAmt = Number(cart.getTaxTotal()); + const itemAmt = Number(cart.getSubTotal() - cart.getDiscounts()); const description = `${shop.name} Ref: ${cartId}`; const { currency } = shop; const options = PayPal.expressCheckoutAccountOptions(); @@ -49,6 +53,9 @@ export const methods = { VERSION: nvpVersion, PAYMENTACTION: "Authorization", AMT: amount, + ITEMAMT: itemAmt, + SHIPPINGAMT: shippingAmt, + TAXAMT: taxAmt, RETURNURL: options.return_url, CANCELURL: options.cancel_url, DESC: description, @@ -90,6 +97,9 @@ export const methods = { throw new Meteor.Error("invalid-parameter", "Bad cart ID"); } const amount = Number(cart.getTotal()); + const shippingAmt = Number(cart.getShippingTotal()); + const taxAmt = Number(cart.getTaxTotal()); + const itemAmt = Number(cart.getSubTotal() - cart.getDiscounts()); const shop = Shops.findOne(cart.shopId); const { currency } = shop; const options = PayPal.expressCheckoutAccountOptions(); @@ -110,6 +120,9 @@ export const methods = { VERSION: nvpVersion, PAYMENTACTION: paymentAction, AMT: amount, + ITEMAMT: itemAmt, + SHIPPINGAMT: shippingAmt, + TAXAMT: taxAmt, METHOD: "DoExpressCheckoutPayment", CURRENCYCODE: currency, TOKEN: token, @@ -151,7 +164,10 @@ export const methods = { * @return {Object} results from PayPal normalized */ "paypalexpress/payment/capture"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); this.unblock(); const options = PayPal.expressCheckoutAccountOptions(); const amount = accounting.toFixed(paymentMethod.amount, 2); @@ -227,8 +243,12 @@ export const methods = { * @return {Object} Transaction results from PayPal normalized */ "paypalexpress/refund/create"(paymentMethod, amount) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); check(amount, Number); + + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); this.unblock(); const options = PayPal.expressCheckoutAccountOptions(); @@ -293,7 +313,10 @@ export const methods = { * @return {array} Refunds from PayPal query, normalized */ "paypalexpress/refund/list"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); this.unblock(); const options = PayPal.expressCheckoutAccountOptions(); diff --git a/imports/plugins/included/payments-paypal/server/methods/payflowproMethods.js b/imports/plugins/included/payments-paypal/server/methods/payflowproMethods.js index 73322b171b3..d6a74fcfbcc 100644 --- a/imports/plugins/included/payments-paypal/server/methods/payflowproMethods.js +++ b/imports/plugins/included/payments-paypal/server/methods/payflowproMethods.js @@ -1,10 +1,9 @@ import { Logger } from "/server/api"; -import { PaymentMethod } from "/lib/collections/schemas"; +import { PaymentMethodArgument } from "/lib/collections/schemas"; import { check } from "meteor/check"; import { PayPal } from "../../lib/api"; // PayPal is the reaction api import { PayflowproApi } from "./payflowproApi"; - /** * payflowpro/payment/submit * Create and Submit a PayPal PayFlow transaction @@ -50,7 +49,10 @@ export function paymentSubmit(transactionType, cardData, paymentData) { * @return {Object} results from PayPal normalized */ export function paymentCapture(paymentMethod) { - check(paymentMethod, PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); const paymentCaptureDetails = { authorizationId: paymentMethod.metadata.authorizationId, @@ -84,9 +86,13 @@ export function paymentCapture(paymentMethod) { * @return {Object} results - Object containing the results of the transaction */ export function createRefund(paymentMethod, amount) { - check(paymentMethod, PaymentMethod); check(amount, Number); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const refundDetails = { captureId: paymentMethod.metadata.captureId, amount @@ -119,7 +125,10 @@ export function createRefund(paymentMethod, amount) { * @return {Array} results - An array of refund objects for display in admin */ export function listRefunds(paymentMethod) { - check(paymentMethod, PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); const refundListDetails = { transactionId: paymentMethod.metadata.transactionId diff --git a/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js b/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js index c3ac41c0714..2463e3cca58 100644 --- a/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js +++ b/imports/plugins/included/payments-stripe/lib/collections/schemas/stripe.js @@ -1,4 +1,6 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; /* @@ -30,49 +32,54 @@ const StripeConnectAuthorizationCredentials = new SimpleSchema({ access_token: { // eslint-disable-line camelcase type: String } -}); +}, { check, tracker: Tracker }); registerSchema("StripeConnectAuthorizationCredentials", StripeConnectAuthorizationCredentials); -export const StripePackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.mode": { - type: Boolean, - defaultValue: false - }, - "settings.api_key": { - type: String, - label: "API Secret Key" - }, - // This field only applies to marketplace style orders where a payment is taken on behalf of another store - "settings.applicationFee": { - type: Number, - label: "Percentage Application Fee", - optional: true, - defaultValue: 5 - }, - "settings.connectAuth": { - type: StripeConnectAuthorizationCredentials, - label: "Connect Authorization Credentials", - optional: true - }, - "settings.reaction-stripe.support": { - type: Array, - label: "Payment provider supported methods" - }, - "settings.reaction-stripe.support.$": { - type: String, - allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] - }, +export const StripePackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.mode": { + type: Boolean, + defaultValue: false + }, + "settings.api_key": { + type: String, + label: "API Secret Key" + }, + // This field only applies to marketplace style orders where a payment is taken on behalf of another store + "settings.applicationFee": { + type: Number, + label: "Percentage Application Fee", + optional: true, + defaultValue: 5 + }, + "settings.connectAuth": { + type: StripeConnectAuthorizationCredentials, + label: "Connect Authorization Credentials", + optional: true + }, + "settings.reaction-stripe.support": { + type: Array, + label: "Payment provider supported methods" + }, + "settings.reaction-stripe.support.$": { + type: String, + allowedValues: ["Authorize", "De-authorize", "Capture", "Refund"] + }, - // Public Settings - "settings.public.client_id": { - type: String, - label: "Public Client ID", - optional: true - } + // Public Settings + "settings.public.client_id": { + type: String, + label: "Public Client ID", + optional: true } -]); +}); registerSchema("StripePackageConfig", StripePackageConfig); @@ -102,6 +109,6 @@ export const StripePayment = new SimpleSchema({ max: 4, label: "CVV" } -}); +}, { check, tracker: Tracker }); registerSchema("StripePayment", StripePayment); diff --git a/imports/plugins/included/payments-stripe/server/methods/stripe.js b/imports/plugins/included/payments-stripe/server/methods/stripe.js index 2fe1659ee65..7eafdd7137b 100644 --- a/imports/plugins/included/payments-stripe/server/methods/stripe.js +++ b/imports/plugins/included/payments-stripe/server/methods/stripe.js @@ -5,6 +5,7 @@ import { check } from "meteor/check"; import { Random } from "meteor/random"; import { Reaction, Logger, Hooks } from "/server/api"; import { Cart, Shops, Accounts, Packages } from "/lib/collections"; +import { PaymentMethodArgument } from "/lib/collections/schemas"; function parseCardData(data) { return { @@ -418,7 +419,10 @@ export const methods = { * @return {Object} results from Stripe normalized */ "stripe/payment/capture"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); const captureDetails = { amount: formatForStripe(paymentMethod.amount) @@ -444,10 +448,14 @@ export const methods = { * @return {Object} result */ "stripe/refund/create"(paymentMethod, amount, reason = "requested_by_customer") { - check(paymentMethod, Reaction.Schemas.PaymentMethod); check(amount, Number); check(reason, String); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + let result; try { const stripeKey = utils.getStripeApi(paymentMethod.paymentPackageId); @@ -484,7 +492,11 @@ export const methods = { * @return {Object} result */ "stripe/refund/list"(paymentMethod) { - check(paymentMethod, Reaction.Schemas.PaymentMethod); + // Call both check and validate because by calling `clean`, the audit pkg + // thinks that we haven't checked paymentMethod arg + check(paymentMethod, Object); + PaymentMethodArgument.validate(PaymentMethodArgument.clean(paymentMethod)); + const stripeKey = utils.getStripeApi(paymentMethod.paymentPackageId); const stripe = stripeNpm(stripeKey); let refundListResults; diff --git a/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-capture.app-test.js b/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-capture.app-test.js index cfd12eb18dc..37153bdb330 100644 --- a/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-capture.app-test.js +++ b/imports/plugins/included/payments-stripe/server/methods/stripeapi-methods-capture.app-test.js @@ -128,7 +128,7 @@ describe("stripe/payment/capture", function () { sandbox.restore(); }); - it("should should return a match error if transactionId is not available", function (done) { + it("should should return an error if transactionId is not available", function (done) { const paymentMethod = { processor: "Stripe", storedCard: "Visa 4242", @@ -150,13 +150,9 @@ describe("stripe/payment/capture", function () { return "sk_fake_fake"; }); - let captureResult = null; - let captureError = null; Meteor.call("stripe/payment/capture", paymentMethod, function (error, result) { - captureResult = result; - captureError = error; - expect(captureError.message).to.equal("Match error: Match error: Transaction id is required"); - expect(captureResult).to.be.undefined; + expect(error.message).to.equal("[Transaction ID is required]"); + expect(result).to.be.undefined; done(); }); }); diff --git a/imports/plugins/included/product-admin/client/containers/productAdmin.js b/imports/plugins/included/product-admin/client/containers/productAdmin.js index 7153e956002..f6c40b86830 100644 --- a/imports/plugins/included/product-admin/client/containers/productAdmin.js +++ b/imports/plugins/included/product-admin/client/containers/productAdmin.js @@ -5,8 +5,8 @@ import { compose } from "recompose"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; import { Reaction } from "/client/api"; -import { ReactionProduct } from "/lib/api"; -import { Tags, Media, Templates } from "/lib/collections"; +import { getPrimaryMediaForItem, ReactionProduct } from "/lib/api"; +import { Tags, Templates } from "/lib/collections"; import { Countries } from "/client/collections"; import { ProductAdmin } from "../components"; @@ -116,12 +116,9 @@ function composer(props, onData) { const selectedVariant = ReactionProduct.selectedVariant(); if (selectedVariant) { - media = Media.find({ - "metadata.variantId": selectedVariant._id - }, { - sort: { - "metadata.priority": 1 - } + media = getPrimaryMediaForItem({ + productId: product._id, + variantId: selectedVariant._id }); } diff --git a/imports/plugins/included/product-detail-simple/client/components/childVariant.js b/imports/plugins/included/product-detail-simple/client/components/childVariant.js index 8ecc24c09d8..7b3a9fc5ee0 100644 --- a/imports/plugins/included/product-detail-simple/client/components/childVariant.js +++ b/imports/plugins/included/product-detail-simple/client/components/childVariant.js @@ -79,15 +79,12 @@ class ChildVariant extends Component { } renderMedia() { - if (this.hasMedia) { - const media = this.primaryMediaItem; - - return ( - - ); - } + const media = this.primaryMediaItem; + if (!media) return null; - return null; + return ( + + ); } renderValidationButton = () => { diff --git a/imports/plugins/included/product-detail-simple/client/components/variantList.js b/imports/plugins/included/product-detail-simple/client/components/variantList.js index 2d0785f64b0..0371e66221b 100644 --- a/imports/plugins/included/product-detail-simple/client/components/variantList.js +++ b/imports/plugins/included/product-detail-simple/client/components/variantList.js @@ -123,12 +123,9 @@ class VariantList extends Component { if (this.props.childVariants) { childVariants = this.props.childVariants.map((childVariant, index) => { - const media = this.props.childVariantMedia.filter((mediaItem) => { - if (mediaItem.metadata.variantId === childVariant._id) { - return true; - } - return false; - }); + const media = this.props.childVariantMedia.filter((mediaItem) => ( + (mediaItem.document.metadata.variantId === childVariant._id) + )); return ( ( class ProductDetailContainer extends Component { @@ -239,28 +239,29 @@ const wrapComponent = (Comp) => ( } render() { - if (_.isEmpty(this.props.product)) { + const { media, product } = this.props; + + if (_.isEmpty(product)) { return ( ); } + return ( - - - } - onAddToCart={this.handleAddToCart} - onCartQuantityChange={this.handleCartQuantityChange} - onViewContextChange={this.handleViewContextChange} - socialComponent={} - topVariantComponent={} - onDeleteProduct={this.handleDeleteProduct} - onProductFieldChange={this.handleProductFieldChange} - {...this.props} - /> - - + + } + onAddToCart={this.handleAddToCart} + onCartQuantityChange={this.handleCartQuantityChange} + onViewContextChange={this.handleViewContextChange} + socialComponent={} + topVariantComponent={} + onDeleteProduct={this.handleDeleteProduct} + onProductFieldChange={this.handleProductFieldChange} + {...this.props} + /> + ); } } @@ -275,15 +276,12 @@ function composer(props, onData) { const viewProductAs = Reaction.getUserPreferences("reaction-dashboard", "viewAs", "administrator"); let productSub; - if (productId) { productSub = Meteor.subscribe("Product", productId, shopIdOrSlug); } - - if (productSub && productSub.ready() && tagSub.ready() && Reaction.Subscriptions.Cart.ready()) { - // Get the product + if (productSub && productSub.ready() + && tagSub.ready() && Reaction.Subscriptions.Cart.ready()) { const product = ReactionProduct.setProduct(productId, variantId); - if (Reaction.hasPermission("createProduct")) { if (!Reaction.getActionView() && Reaction.isActionViewOpen() === true) { Reaction.setActionView({ @@ -300,30 +298,32 @@ function composer(props, onData) { tags = _.map(product.hashtags, (id) => Tags.findOne(id)); } + Meteor.subscribe("ProductMedia", product._id); + let mediaArray = []; const selectedVariant = ReactionProduct.selectedVariant(); if (selectedVariant) { // Find the media for the selected variant - mediaArray = Media.find({ + mediaArray = Media.findLocal({ "metadata.variantId": selectedVariant._id }, { sort: { "metadata.priority": 1 } - }).fetch(); + }); // If no media found, broaden the search to include other media from parents if (Array.isArray(mediaArray) && mediaArray.length === 0 && selectedVariant.ancestors) { // Loop through ancestors in reverse to find a variant that has media to use for (const ancestor of selectedVariant.ancestors.reverse()) { - const media = Media.find({ + const media = Media.findLocal({ "metadata.variantId": ancestor }, { sort: { "metadata.priority": 1 } - }).fetch(); + }); // If we found some media, then stop here if (Array.isArray(media) && media.length) { diff --git a/imports/plugins/included/product-detail-simple/client/containers/publish.js b/imports/plugins/included/product-detail-simple/client/containers/publish.js index 792b408aa04..07a10f6dd19 100644 --- a/imports/plugins/included/product-detail-simple/client/containers/publish.js +++ b/imports/plugins/included/product-detail-simple/client/containers/publish.js @@ -2,7 +2,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; -import { Router } from "/client/api"; import { ReactionProduct } from "/lib/api"; import { Products } from "/lib/collections"; import PublishContainer from "/imports/plugins/core/revisions/client/containers/publishContainer"; @@ -17,12 +16,15 @@ class ProductPublishContainer extends Component { } handleVisibilityChange = (event, isProductVisible) => { + const { product } = this.props; + if (!product) return; + // Update main product - Meteor.call("products/updateProductField", this.props.product._id, "isVisible", isProductVisible); + Meteor.call("products/updateProductField", product._id, "isVisible", isProductVisible); const variants = Products.find({ ancestors: { - $in: [this.props.product._id] + $in: [product._id] } }).fetch(); @@ -37,27 +39,10 @@ class ProductPublishContainer extends Component { } } - handlePublishSuccess = (result) => { - if (result && result.status === "success" && this.props.product) { - const productDocument = result.previousDocuments.find((product) => this.props.product._id === product._id); - - if (productDocument && this.props.product.handle !== productDocument.handle) { - const newProductPath = Router.pathFor("product", { - hash: { - handle: this.props.product.handle - } - }); - - window.location.href = newProductPath; - } - } - } - render() { return ( @@ -69,10 +54,8 @@ class ProductPublishContainer extends Component { function composer(props, onData) { const product = ReactionProduct.selectedProduct(); let revisonDocumentIds; - if (product) { revisonDocumentIds = [product._id]; - onData(null, { product, documentIds: revisonDocumentIds, diff --git a/imports/plugins/included/product-detail-simple/client/containers/social.js b/imports/plugins/included/product-detail-simple/client/containers/social.js index f5ec23ba165..664df3c1729 100644 --- a/imports/plugins/included/product-detail-simple/client/containers/social.js +++ b/imports/plugins/included/product-detail-simple/client/containers/social.js @@ -1,11 +1,10 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { composeWithTracker } from "@reactioncommerce/reaction-components"; -import { ReactionProduct } from "/lib/api"; +import { getPrimaryMediaForItem, ReactionProduct } from "/lib/api"; import SocialButtons from "/imports/plugins/included/social/client/components/socialButtons"; import { createSocialSettings } from "/imports/plugins/included/social/lib/helpers"; import { EditContainer } from "/imports/plugins/core/ui/client/containers"; -import { Media } from "/lib/collections"; class ProductSocialContainer extends Component { render() { @@ -40,21 +39,13 @@ function composer(props, onData) { description = product.description.substring(0, 254); } - const media = Media.findOne({ - "metadata.variantId": { - $in: [ - selectedVariant._id, - product._id - ] - } - }, { - sort: { - "metadata.priority": 1 - } + const media = getPrimaryMediaForItem({ + productId: product._id, + variantId: selectedVariant && selectedVariant._id }); if (media) { - mediaUrl = media.url(); + mediaUrl = media.url({ store: "large" }); } const options = { diff --git a/imports/plugins/included/product-detail-simple/client/containers/variantList.js b/imports/plugins/included/product-detail-simple/client/containers/variantList.js index b074cd9353e..b61b5ef3320 100644 --- a/imports/plugins/included/product-detail-simple/client/containers/variantList.js +++ b/imports/plugins/included/product-detail-simple/client/containers/variantList.js @@ -6,10 +6,10 @@ import { composeWithTracker, Components } from "@reactioncommerce/reaction-compo import { ReactionProduct } from "/lib/api"; import { Reaction, i18next } from "/client/api"; import { getChildVariants } from "../selectors/variants"; -import { Products, Media } from "/lib/collections"; +import { Products } from "/lib/collections"; import update from "immutability-helper"; import { getVariantIds } from "/lib/selectors/variants"; -import { DragDropProvider } from "/imports/plugins/core/ui/client/providers"; +import { Media } from "/imports/plugins/core/files/client"; function variantIsSelected(variantId) { const current = ReactionProduct.selectedVariant(); @@ -159,7 +159,7 @@ class VariantListContainer extends Component { render() { return ( - + - + ); } } @@ -179,7 +179,7 @@ function composer(props, onData) { const childVariants = getChildVariants(); if (Array.isArray(childVariants)) { - childVariantMedia = Media.find({ + childVariantMedia = Media.findLocal({ "metadata.variantId": { $in: getVariantIds(childVariants) } @@ -187,7 +187,7 @@ function composer(props, onData) { sort: { "metadata.priority": 1 } - }).fetch(); + }); } let editable; diff --git a/imports/plugins/included/product-variant/client/index.js b/imports/plugins/included/product-variant/client/index.js index 73d46c93628..be764e791c9 100644 --- a/imports/plugins/included/product-variant/client/index.js +++ b/imports/plugins/included/product-variant/client/index.js @@ -1,22 +1,6 @@ -import "./templates/products/productDetail/variants/variantList/variantList.html"; -import "./templates/products/productDetail/variants/variantList/variantList.js"; -import "./templates/products/productDetail/variants/variant.html"; -import "./templates/products/productDetail/variants/variant.js"; -import "./templates/products/productDetail/attributes.html"; -import "./templates/products/productDetail/attributes.js"; -import "./templates/products/productDetail/edit.html"; -import "./templates/products/productDetail/edit.js"; -import "./templates/products/productDetail/productImageGallery.html"; -import "./templates/products/productDetail/productImageGallery.js"; -import "./templates/products/productDetail/social.html"; -import "./templates/products/productDetail/social.js"; - import "./templates/products/productGrid/publishControls.html"; import "./templates/products/productGrid/publishControls.js"; -import "./templates/products/productList/productList.html"; -import "./templates/products/productList/productList.js"; - import "./templates/products/productSettings/productSettings.html"; import "./templates/products/productSettings/productSettings.js"; @@ -40,5 +24,7 @@ export { default as GridPublishContainer } from "../containers/gridPublishContai export { default as ProductGridContainer } from "../containers/productGridContainer"; export { default as ProductGridItemsContainer } from "../containers/productGridItemsContainer"; export { default as ProductsContainer } from "../containers/productsContainer"; +export { default as ProductsContainerAdmin } from "../containers/productsContainerAdmin"; +export { default as ProductsContainerCustomer } from "../containers/productsContainerCustomer"; export { default as VariantFormContainer } from "../containers/variantFormContainer"; export { default as VariantEditContainer } from "../containers/variantEditContainer"; diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.html deleted file mode 100644 index a921632d301..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.js deleted file mode 100644 index 3fa55e1b111..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.js +++ /dev/null @@ -1,84 +0,0 @@ -import { ReactionProduct } from "/lib/api"; -import { Meteor } from "meteor/meteor"; -import { Tracker } from "meteor/tracker"; -import { Template } from "meteor/templating"; - -/** - * metaComponent helpers - */ - -Template.metaComponent.helpers({ - buttonProps() { - const currentData = Template.currentData(); - - return { - icon() { - if (currentData.createNew) { - return "plus"; - } - - return "times-circle"; - }, - onClick() { - if (!currentData.createNew) { - const productId = ReactionProduct.selectedProductId(); - Meteor.call("products/removeMetaFields", productId, currentData); - } - } - }; - } -}); - - -Template.metaComponent.events({ - "change input"(event) { - const productId = ReactionProduct.selectedProductId(); - const updateMeta = { - key: Template.instance() - .$(event.currentTarget) - .parent() - .children(".metafield-key-input") - .val(), - value: Template.instance() - .$(event.currentTarget) - .parent() - .children(".metafield-value-input") - .val() - }; - - if (this.key) { - const index = Template.instance().$(event.currentTarget).closest(".metafield-list-item").index(); - Meteor.call("products/updateMetaFields", productId, updateMeta, index); - Template.instance().$(event.currentTarget).animate({ - backgroundColor: "#e2f2e2" - }).animate({ - backgroundColor: "#fff" - }); - return Tracker.flush(); - } - - if (updateMeta.value && !updateMeta.key) { - Template.instance() - .$(event.currentTarget) - .parent() - .children(".metafield-key-input") - .val("") - .focus(); - } - if (updateMeta.key && updateMeta.value) { - Meteor.call("products/updateMetaFields", productId, updateMeta); - Tracker.flush(); - Template.instance() - .$(event.currentTarget) - .parent() - .children(".metafield-key-input") - .val("") - .focus(); - return Template.instance() - .$(event.currentTarget) - .parent() - .children(".metafield-value-input") - .val(""); - } - } -}); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.html deleted file mode 100644 index df88841b9f2..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.html +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.js deleted file mode 100644 index d8241cf35c2..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/edit.js +++ /dev/null @@ -1,105 +0,0 @@ -import autosize from "autosize"; -import { Meteor } from "meteor/meteor"; -import { Session } from "meteor/session"; -import { Template } from "meteor/templating"; -import { $ } from "meteor/jquery"; -import { Reaction, i18next, Logger } from "/client/api"; -import { ReactionProduct } from "/lib/api"; - - -/** - * productDetailEdit helpers - */ - -Template.productDetailEdit.helpers({ - i18nPlaceholder() { - const i18nKey = `productDetailEdit.${this.field}`; - if (i18next.t(i18nKey) === i18nKey) { - Logger.warn(`returning empty placeholder productDetailEdit: ${i18nKey} no i18n key found.`); - } else { - return i18next.t(i18nKey); - } - } -}); - -/** - * productDetailEdit events - */ - -Template.productDetailEdit.events({ - "change input,textarea"(event) { - const self = this; - const productId = ReactionProduct.selectedProductId(); - Meteor.call( - "products/updateProductField", productId, self.field, - Template.instance().$(event.currentTarget).val(), - (error, result) => { - if (error) { - return Alerts.inline(error.reason, "error", { - placement: "productManagement", - i18nKey: "productDetail.errorMsg", - id: self._id - }); - } - if (result) { - // redirect to new url on title change - if (self.field === "title") { - Meteor.call( - "products/setHandle", productId, - (err, res) => { - Alerts.removeSeen(); - if (err) { - Alerts.removeType("error"); - Alerts.inline(err.reason, "error", { - placement: "productManagement", - i18nKey: "productDetail.errorMsg", - id: self._id - }); - } - if (res) { - Reaction.Router.go("product", { - handle: res - }); - } - } - ); - } - // animate updated field - // TODO this needs to be moved into a component - return Template.instance().$(event.currentTarget).animate({ - backgroundColor: "#e2f2e2" - }).animate({ - backgroundColor: "#fff" - }); - } - } - ); - - if (this.type === "textarea") { - autosize(Template.instance().$(event.currentTarget)); - } - - return Session.set(`editing-${this.field}`, false); - } -}); - -/** - * productDetailField events - */ - -Template.productDetailField.events({ - "click .product-detail-field"() { - if (Reaction.hasPermission("createProduct")) { - const fieldClass = `editing-${this.field}`; - Session.set(fieldClass, true); - // Tracker.flush(); - return $(`.${this.field}-edit-input`).focus(); - } - } -}); - -/** - * productDetailEdit onRendered - */ - -Template.productDetailEdit.onRendered(() => autosize($("textarea"))); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.html deleted file mode 100644 index 892de744b90..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.js deleted file mode 100644 index f4c79456186..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/productImageGallery.js +++ /dev/null @@ -1,202 +0,0 @@ -import { $ } from "meteor/jquery"; -import { Reaction } from "/client/api"; -import { ReactionProduct } from "/lib/api"; -import { Media } from "/lib/collections"; -import { Meteor } from "meteor/meteor"; -import { Session } from "meteor/session"; -import { Template } from "meteor/templating"; -import Sortable from "sortablejs"; - -/** - * productImageGallery helpers - */ - -/* - * uploadHandler method - */ -function uploadHandler(event) { - // TODO: It would be cool to move this logic to common ValidatedMethod, but - // I can't find a way to do this, because of browser's `FileList` collection - // and it `Blob`s which is our event.target.files. - // There is a way to do this: http://stackoverflow.com/a/24003932. but it's too - // tricky - const productId = ReactionProduct.selectedProductId(); - const variant = ReactionProduct.selectedVariant(); - if (typeof variant !== "object") { - return Alerts.add("Please, create new Variant first.", "danger", { - autoHide: true - }); - } - const variantId = variant._id; - const shopId = ReactionProduct.selectedProduct().shopId || Reaction.getShopId(); - const userId = Meteor.userId(); - let count = Media.find({ - "metadata.variantId": variantId - }).count(); - // TODO: we need to mark the first variant images somehow for productGrid. - // But how do we know that this is the first, not second or other variant? - // Question is open. For now if product has more than 1 top variant, everyone - // will have a chance to be displayed - const toGrid = variant.ancestors.length >= 1; - - return FS.Utility.eachFile(event, (file) => { - const fileObj = new FS.File(file); - fileObj.metadata = { - ownerId: userId, - productId, - variantId, - shopId, - priority: count, - toGrid: +toGrid // we need number - }; - Media.insert(fileObj); - count += 1; - return count; - }); -} - -/* - * updateImagePriorities method - */ -function updateImagePriorities() { - $(".gallery > .gallery-image") - .toArray() - .map((element, index) => { - const mediaId = element.getAttribute("data-index"); - - return Media.update(mediaId, { - $set: { - "metadata.priority": index - } - }); - }); -} - -/** - * Product Image Gallery - */ - -Template.productImageGallery.helpers({ - media() { - let mediaArray = []; - const variant = ReactionProduct.selectedVariant(); - - if (variant) { - mediaArray = Media.find({ - "metadata.variantId": variant._id - }, { - sort: { - "metadata.priority": 1 - } - }); - } - return mediaArray; - }, - variant() { - return ReactionProduct.selectedVariant(); - } -}); - -/** - * productImageGallery onRendered - */ - -Template.productImageGallery.onRendered(function () { - this.autorun(function () { - let $gallery; - if (Reaction.hasAdminAccess()) { - [$gallery] = $(".gallery"); - - this.sortable = Sortable.create($gallery, { - group: "gallery", - handle: ".gallery-image", - onUpdate() { - updateImagePriorities(); - } - }); - } - }); -}); - -/* - * productImageGallery events - */ - -Template.productImageGallery.events({ - "mouseenter .gallery > li"(event) { - event.stopImmediatePropagation(); - // This is a workaround for an issue with FF refiring mouseover when the contents change - if (event.relatedTarget === null) { - return undefined; - } - if (!Reaction.hasPermission("createProduct")) { - const first = $(".gallery li:nth-child(1)"); - const target = Template.instance().$(event.currentTarget); - if ($(target).data("index") !== first.data("index")) { - return $(".gallery li:nth-child(1)").fadeOut(400, function () { - $(this).replaceWith(target); - first.css({ - display: "inline-block" - }).appendTo($(".gallery")); - return $(".gallery li:last-child").fadeIn(100); - }); - } - } - return undefined; - }, - "click .remove-image"() { - const imageUrl = - $(event.target) - .closest(".gallery-image") - .find("img") - .attr("src"); - - Alerts.alert({ - title: "Remove Media?", - type: "warning", - showCancelButton: true, - imageUrl, - imageHeight: 150 - }, (isConfirm) => { - if (isConfirm) { - const mediaId = this._id; - - Media.remove({ _id: mediaId }, (error) => { - if (error) { - Alerts.toast(error.reason, "warning", { - autoHide: 10000 - }); - } - - updateImagePriorities(); - }); - } - }); - }, - "dropped #galleryDropPane": uploadHandler -}); - -/* - * imageUploader events - */ - -Template.imageUploader.events({ - "click #btn-upload"() { - return $("#files").click(); - }, - "change #files": uploadHandler, - "dropped #dropzone": uploadHandler -}); - -/* - * productImageGallery events - */ - -Template.productImageGallery.events({ - "click #img-upload"() { - return $("#files").click(); - }, - "load .img-responsive"(event, template) { - return Session.set("variantImgSrc", template.$(".img-responsive").attr("src")); - } -}); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/social.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/social.html deleted file mode 100644 index 9bf6156f65c..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/social.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/social.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/social.js deleted file mode 100644 index 6c98b5e4d27..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/social.js +++ /dev/null @@ -1,43 +0,0 @@ -import { ReactionProduct } from "/lib/api"; -import { Session } from "meteor/session"; -import { Template } from "meteor/templating"; - -/** -* productSocial helpers -*/ - -Template.productSocial.helpers({ - customSocialSettings() { - const product = ReactionProduct.selectedProduct(); - let { title } = product; - if (ReactionProduct.selectedVariant()) { - ({ title } = ReactionProduct.selectedVariant()); - } - - const settings = { - placement: "productDetail", - faClass: "", - faSize: "fa-lg", - media: Session.get("variantImgSrc"), - url: window.location.href, - title: title || "", - description: typeof product.description === "string" ? product.description.substring(0, 254) : undefined, - apps: { - facebook: { - description: product.facebookMsg - }, - twitter: { - title: product.twitterMsg - }, - googleplus: { - itemtype: "Product", - description: product.googleplusMsg - }, - pinterest: { - description: product.pinterestMsg - } - } - }; - return settings; - } -}); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.html deleted file mode 100644 index 99bb7b42ee2..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.html +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.js deleted file mode 100644 index 232cfaaa5c8..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variant.js +++ /dev/null @@ -1,116 +0,0 @@ -import { Components } from "@reactioncommerce/reaction-components"; -import { Reaction } from "/client/api"; -import { ReactionProduct } from "/lib/api"; -import { Session } from "meteor/session"; -import { Template } from "meteor/templating"; - -// Duplicated in variantList/variantList.js -function variantIsSelected(variantId) { - const current = ReactionProduct.selectedVariant(); - if (typeof current === "object" && (variantId === current._id || current.ancestors.indexOf(variantId) >= 0)) { - return true; - } - - return false; -} - -function variantIsInActionView(variantId) { - const actionViewVariant = Reaction.getActionView().data; - - if (actionViewVariant) { - // Check if the variant is selected, and also visible & selected in the action view - return variantIsSelected(variantId) && variantIsSelected(actionViewVariant._id) && Reaction.isActionViewOpen(); - } - - return false; -} - - -/** - * variant helpers - */ - -Template.variant.helpers({ - progressBar() { - if (this.inventoryPercentage <= 10) { - return "progress-bar-danger"; - } else if (this.inventoryPercentage <= 30) { - return "progress-bar-warning"; - } - return "progress-bar-success"; - }, - selectedVariant() { - if (variantIsSelected(this._id)) { - return "variant-detail-selected"; - } - - return null; - }, - displayQuantity() { - return ReactionProduct.getVariantQuantity(this); - }, - displayPrice() { - return ReactionProduct.getVariantPriceRange(this._id); - }, - isSoldOut() { - return ReactionProduct.getVariantQuantity(this) < 1; - }, - EditButton() { - const data = Template.currentData(); - - return { - component: Components.EditButton, - toggleOn: variantIsInActionView(data._id), - onClick() { - showVariant(data); - } - }; - }, - VariantRevisionButton() { - const variant = Template.currentData(); - - return { - component: Components.VisibilityButton, - toggleOn: variant.isVisible, - onClick(event) { - event.stopPropagation(); - ReactionProduct.toggleVisibility(variant); - } - }; - } -}); - -/** - * variant events - */ - -function showVariant(variant) { - const selectedProduct = ReactionProduct.selectedProduct(); - - ReactionProduct.setCurrentVariant(variant._id); - Session.set(`variant-form-${variant._id}`, true); - Reaction.Router.go("product", { handle: selectedProduct.handle, variantId: variant._id }); - - if (Reaction.hasPermission("createProduct")) { - Reaction.showActionView({ - label: "Edit Variant", - i18nKeyLabel: "productDetailEdit.editVariant", - template: "variantForm", - data: variant - }); - } -} - -Template.variant.events({ - "click .variant-edit"() { - showVariant(this); - }, - "dblclick .variant-detail"() { - showVariant(this); - }, - "click .variant-detail"() { - Alerts.removeSeen(); - - ReactionProduct.setCurrentVariant(this._id); - } -}); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.html deleted file mode 100644 index 2a6adef4d5b..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.html +++ /dev/null @@ -1,40 +0,0 @@ - diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.js b/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.js deleted file mode 100644 index 2d88e651b18..00000000000 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/variants/variantList/variantList.js +++ /dev/null @@ -1,216 +0,0 @@ -import Sortable from "sortablejs"; -import { Components } from "@reactioncommerce/reaction-components"; -import { $ } from "meteor/jquery"; -import { Tracker } from "meteor/tracker"; -import { Meteor } from "meteor/meteor"; -import { Session } from "meteor/session"; -import { Template } from "meteor/templating"; -import { Reaction } from "/client/api"; -import { ReactionProduct } from "/lib/api"; -import { Products, Media } from "/lib/collections"; - -function variantIsSelected(variantId) { - const current = ReactionProduct.selectedVariant(); - if (typeof current === "object" && (variantId === current._id || current.ancestors.indexOf(variantId) >= 0)) { - return true; - } - - return false; -} - -function variantIsInActionView(variantId) { - const actionViewVariant = Reaction.getActionView().data; - - if (actionViewVariant) { - // Check if the variant is selected, and also visible & selected in the action view - return variantIsSelected(variantId) && variantIsSelected(actionViewVariant._id) && Reaction.isActionViewOpen(); - } - - return false; -} - -/** - * variant onRendered - */ - -Template.variantList.onRendered(function () { - const instance = this; - - return this.autorun(function () { - if (Reaction.hasPermission("createProduct")) { - const variantSort = $(".variant-list")[0]; - - this.sortable = Sortable.create(variantSort, { - group: "variant-list", - handle: ".variant-list-item", - onUpdate() { - const positions = instance.$(".variant-list-item") - .toArray() - .map((element) => element.getAttribute("data-id")); - - Meteor.defer(() => { - Meteor.call("products/updateVariantsPosition", positions); - }); - - Tracker.flush(); - } - }); - } - }); -}); - - -/** - * variantList helpers - */ -Template.variantList.helpers({ - media() { - const media = Media.findOne({ - "metadata.variantId": this._id - }, { - sort: { - "metadata.priority": 1 - } - }); - - return media instanceof FS.File ? media : false; - }, - variants() { - let inventoryTotal = 0; - const variants = ReactionProduct.getTopVariants(); - if (variants.length) { - // calculate inventory total for all variants - for (const variant of variants) { - if (variant.inventoryManagement) { - const qty = ReactionProduct.getVariantQuantity(variant); - if (typeof qty === "number") { - inventoryTotal += qty; - } - } - } - // calculate percentage of total inventory of this product - for (const variant of variants) { - const qty = ReactionProduct.getVariantQuantity(variant); - variant.inventoryTotal = inventoryTotal; - if (variant.inventoryManagement && inventoryTotal) { - variant.inventoryPercentage = parseInt(qty / inventoryTotal * 100, 10); - } else { - // for cases when sellers doesn't use inventory we should always show - // "green" progress bar - variant.inventoryPercentage = 100; - } - if (variant.title) { - variant.inventoryWidth = parseInt(variant.inventoryPercentage - - variant.title.length, 10); - } else { - variant.inventoryWidth = 0; - } - } - // sort variants in correct order - variants.sort((a, b) => a.index - b.index); - - return variants; - } - return []; - }, - childVariants() { - const childVariants = []; - const variants = ReactionProduct.getVariants(); - if (variants.length > 0) { - const current = ReactionProduct.selectedVariant(); - - if (!current) { - return []; - } - - if (current.ancestors.length === 1) { - variants.map((variant) => { - if (typeof variant.ancestors[1] === "string" && - variant.ancestors[1] === current._id && - variant.optionTitle && - variant.type !== "inventory") { - childVariants.push(variant); - } - return childVariants; - }); - } else { - // TODO not sure we need this part... - variants.map((variant) => { - if (typeof variant.ancestors[1] === "string" && - variant.ancestors.length === current.ancestors.length && - variant.ancestors[1] === current.ancestors[1] && - variant.optionTitle - ) { - childVariants.push(variant); - } - return childVariants; - }); - } - - return childVariants; - } - - return null; - }, - selectedVariant() { - if (variantIsSelected(this._id)) { - return "variant-detail-selected"; - } - - return null; - }, - ChildVariantEditButton() { - const variant = Template.currentData(); - const parentVariant = Products.findOne(variant.ancestors[1]); - - return { - component: Components.EditButton, - toggleOn: variantIsInActionView(variant._id), - onClick() { - ReactionProduct.setCurrentVariant(variant._id); - Session.set(`variant-form-${parentVariant._id}`, true); - - if (Reaction.hasPermission("createProduct")) { - Reaction.showActionView({ - label: "Edit Variant", - i18nKeyLabel: "productDetailEdit.editVariant", - template: "variantForm", - data: parentVariant - }); - } - } - }; - }, - ChildVariantRevisionButton() { - const variant = Template.currentData(); - // const parentVariant = Products.findOne(variant.ancestors[1]); - - return { - component: Components.VisibilityButton, - toggleOn: variant.isVisible, - onClick() { - ReactionProduct.toggleVisibility(variant); - } - }; - } -}); - -/** - * variantList events - */ - -Template.variantList.events({ - "click #create-variant"() { - return Meteor.call("products/createVariant", this._id); - }, - "click .variant-select-option"(event, templateInstance) { - templateInstance.$(".variant-select-option").removeClass("active"); - $(event.target).addClass("active"); - Alerts.removeSeen(); - - const selectedProduct = ReactionProduct.selectedProduct(); - Reaction.Router.go("product", { handle: selectedProduct.handle, variantId: this._id }); - - return ReactionProduct.setCurrentVariant(this._id); - } -}); diff --git a/imports/plugins/included/product-variant/client/templates/products/productList/productList.html b/imports/plugins/included/product-variant/client/templates/products/productList/productList.html index fd1857cb2cf..2e0cd2e9e18 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productList/productList.html +++ b/imports/plugins/included/product-variant/client/templates/products/productList/productList.html @@ -6,11 +6,7 @@ - {{#with media}} - - {{else}} - - {{/with}} + diff --git a/imports/plugins/included/product-variant/client/templates/products/productList/productList.js b/imports/plugins/included/product-variant/client/templates/products/productList/productList.js index 8ab1e97d146..04fd82c0d44 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productList/productList.js +++ b/imports/plugins/included/product-variant/client/templates/products/productList/productList.js @@ -1,6 +1,5 @@ import { Template } from "meteor/templating"; -import { ReactionProduct } from "/lib/api"; -import { Media } from "/lib/collections"; +import { getPrimaryMediaForItem, ReactionProduct } from "/lib/api"; /** * productList helpers @@ -10,20 +9,11 @@ Template.productList.helpers({ products() { return ReactionProduct.getProductsByTag(this.tag); }, - media() { - let defaultImage; + mediaUrl() { const variants = ReactionProduct.getTopVariants(); - if (variants.length > 0) { - const variantId = variants[0]._id; - defaultImage = Media.findOne({ - "metadata.variantId": variantId - }, { - sort: { "metadata.priority": 1, "uploadedAt": 1 } - }); - } - if (defaultImage) { - return defaultImage; - } - return false; + if (!variants || variants.length === 0) return "/resources/placeholder.gif"; + const media = getPrimaryMediaForItem({ variantId: variants[0]._id }); + if (!media) return "/resources/placeholder.gif"; + return media.url({ store: "large" }); } }); diff --git a/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.html b/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.html index e9568026e59..08d21dd5c3b 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.html +++ b/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.html @@ -109,11 +109,7 @@

Size

data-event-value="{{_id}}" >
- {{#with media}} - - {{else}} - - {{/with}} +
diff --git a/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.js b/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.js index 2fbf7ef86b7..486a9bdc312 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.js +++ b/imports/plugins/included/product-variant/client/templates/products/productSettings/productSettings.js @@ -4,8 +4,8 @@ import { Meteor } from "meteor/meteor"; import { ReactiveDict } from "meteor/reactive-dict"; import { Reaction } from "/client/api"; import Logger from "/client/modules/logger"; -import { ReactionProduct } from "/lib/api"; -import { Media, Products } from "/lib/collections"; +import { getPrimaryMediaForItem, ReactionProduct } from "/lib/api"; +import { Products } from "/lib/collections"; import { isRevisionControlEnabled } from "/imports/plugins/core/revisions/lib/api"; import { applyProductRevision } from "/lib/api/products"; @@ -72,7 +72,7 @@ Template.productSettings.helpers({ Template.productSettingsListItem.events({ "click [data-event-action=product-click]"() { Reaction.Router.go("product", { - handle: this.handle + handle: (this.__published && this.__published.handle) || this.handle }); Reaction.state.set("edit/focus", "productDetails"); @@ -94,14 +94,12 @@ Template.productSettingsListItem.helpers({ return null; }, - media() { - const media = Media.findOne({ - "metadata.productId": this._id, - "metadata.workflow": { $nin: ["archived"] }, - "metadata.toGrid": 1 - }, { sort: { uploadedAt: 1 } }); - - return media instanceof FS.File ? media : false; + mediaUrl() { + const variants = ReactionProduct.getTopVariants(this._id); + if (!variants || variants.length === 0) return "/resources/placeholder.gif"; + const media = getPrimaryMediaForItem({ productId: this._id, variantId: variants[0]._id }); + if (!media) return "/resources/placeholder.gif"; + return media.url({ store: "thumbnail" }); }, listItemActiveClassName(productId) { diff --git a/imports/plugins/included/product-variant/components/customer/productGrid.js b/imports/plugins/included/product-variant/components/customer/productGrid.js new file mode 100644 index 00000000000..49ea474e4aa --- /dev/null +++ b/imports/plugins/included/product-variant/components/customer/productGrid.js @@ -0,0 +1,90 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Components } from "@reactioncommerce/reaction-components"; +import ProductGridItem from "./productGridItem"; +import { ReactionProduct } from "/lib/api"; + +class ProductGrid extends Component { + static propTypes = { + canLoadMoreProducts: PropTypes.bool, + loadProducts: PropTypes.func, + products: PropTypes.array, + productsSubscription: PropTypes.object + } + + componentDidMount() { + window.addEventListener("scroll", this.loadMoreProducts); + } + + componentWillUnmount() { + window.removeEventListener("scroll", this.loadMoreProducts); + } + + // load more products to the grid + loadMoreProducts = (event) => { + const { canLoadMoreProducts, loadProducts } = this.props; + const { scrollY, innerHeight } = window; + const { body: { scrollHeight } } = document; + const atBottom = (innerHeight + scrollY === scrollHeight); + + if (canLoadMoreProducts && atBottom) { + loadProducts(event); + } + } + + // render the loading spinner + renderLoadingSpinner() { + const { productsSubscription: { ready } } = this.props; + // if the products catalog is not ready + // show the loading spinner + if (!ready()) return ; + } + + // render the No Products Found message + renderNotFound() { + const { products, productsSubscription: { ready } } = this.props; + // if the products subscription is ready & the products array is undefined or empty + // show the Not Found message + if (ready() && (!Array.isArray(products) || !products.length)) { + return ( + + ); + } + } + + // render the product grid + renderProductGrid() { + const { products } = this.props; + const currentTag = ReactionProduct.getTag(); + + return ( +
+
    + {products.map((product) => ( + + ))} +
+
+ ); + } + + render() { + return ( +
+ {this.renderProductGrid()} + {this.renderLoadingSpinner()} + {this.renderNotFound()} +
+ ); + } +} + +export default ProductGrid; diff --git a/imports/plugins/included/product-variant/components/customer/productGridItem.js b/imports/plugins/included/product-variant/components/customer/productGridItem.js new file mode 100644 index 00000000000..23c95fa611f --- /dev/null +++ b/imports/plugins/included/product-variant/components/customer/productGridItem.js @@ -0,0 +1,191 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import classnames from "classnames"; +import { formatPriceString, Router } from "/client/api"; +import { Components } from "@reactioncommerce/reaction-components"; + +class ProductGridItem extends Component { + static propTypes = { + isSearch: PropTypes.bool, + position: PropTypes.object, + product: PropTypes.object + } + + // get product detail page URL + get productURL() { + const { product: { handle } } = this.props; + return Router.pathFor("product", { + hash: { + handle + } + }); + } + + // get weight class name + get weightClass() { + const { weight } = this.props.position || { weight: 0 }; + switch (weight) { + case 1: + return "product-medium"; + case 2: + return "product-large"; + default: + return "product-small"; + } + } + + // get notice class names + get noticeClassNames() { + const { product: { isSoldOut, isLowQuantity, isBackorder } } = this.props; + return classnames({ + "badge": (isSoldOut || isLowQuantity), + "variant-qty-sold-out": (isSoldOut || (isSoldOut && isBackorder)), + "badge-danger": (isSoldOut && !isBackorder), + "badge-low-inv-warning": (isLowQuantity && !isSoldOut) + }); + } + + // get product item class names + get productClassNames() { + const { position } = this.props; + return classnames({ + "product-grid-item": true, + [this.weightClass]: true, + "pinned": position.pinned + }); + } + + // handle click event + handleClick = (event) => { + event.preventDefault(); + Router.go(this.productURL); + } + + // notice + renderNotices() { + const { product: { isSoldOut, isLowQuantity, isBackorder } } = this.props; + const noticeContent = { classNames: this.noticeClassNames }; + + if (isSoldOut) { + if (isBackorder) { + noticeContent.defaultValue = "Backorder"; + noticeContent.i18nKey = "productDetail.backOrder"; + } else { + noticeContent.defaultValue = "Sold Out!"; + noticeContent.i18nKey = "productDetail.soldOut"; + } + } else if (isLowQuantity) { + noticeContent.defaultValue = "Limited Supply"; + noticeContent.i18nKey = "productDetail.limitedSupply"; + } + + return ( +
+
+ + + +
+
+ ); + } + + // render product image + renderMedia() { + const { product } = this.props; + const MEDIA_PLACEHOLDER = "/resources/placeholder.gif"; + const { large } = (Array.isArray(product.media) && product.media[0]) || { large: MEDIA_PLACEHOLDER }; + + return ( + + ); + } + + + renderAdditionalMedia() { + const { product: { media }, position: { weight } } = this.props; + + // if product is not medium weight + // or the media array is empty exit + if (weight !== 1 || (!media || media.length === 0)) return; + + // creating an additional madia array with + // the 2nd, 3rd and 4th images returned + // in the media array + const additionalMedia = [...media.slice(1, 4)]; + + return ( +
+ {additionalMedia.map((img) => ( + + ))} +
+ ); + } + + renderGridContent() { + const { product } = this.props; + + return ( + + ); + } + + render() { + const { product, isSearch } = this.props; + return ( +
  • +
    + + + +
    + {this.renderMedia()} +
    + + {this.renderAdditionalMedia()} +
    + + {!isSearch && this.renderNotices()} + {this.renderGridContent()} +
    +
  • + ); + } +} + +export default ProductGridItem; diff --git a/imports/plugins/included/product-variant/components/productGrid.js b/imports/plugins/included/product-variant/components/productGrid.js index 5e82eac70a1..931baab3061 100644 --- a/imports/plugins/included/product-variant/components/productGrid.js +++ b/imports/plugins/included/product-variant/components/productGrid.js @@ -4,18 +4,29 @@ import { Components } from "@reactioncommerce/reaction-components"; class ProductGrid extends Component { static propTypes = { - products: PropTypes.array + productMediaById: PropTypes.object, + products: PropTypes.arrayOf(PropTypes.object) } + static defaultProps = { + productMediaById: {} + }; + renderProductGridItems = (products) => { if (Array.isArray(products)) { + const { productMediaById } = this.props; + return products.map((product, index) => ( )); } + return (
    diff --git a/imports/plugins/included/product-variant/components/productGridItems.js b/imports/plugins/included/product-variant/components/productGridItems.js index 261a1a5b610..0be5ae89e5e 100644 --- a/imports/plugins/included/product-variant/components/productGridItems.js +++ b/imports/plugins/included/product-variant/components/productGridItems.js @@ -5,7 +5,6 @@ import { formatPriceString } from "/client/api"; class ProductGridItems extends Component { static propTypes = { - additionalMedia: PropTypes.func, canEdit: PropTypes.bool, connectDragSource: PropTypes.func, connectDropTarget: PropTypes.func, @@ -13,25 +12,30 @@ class ProductGridItems extends Component { isMediumWeight: PropTypes.func, isSearch: PropTypes.bool, isSelected: PropTypes.func, - media: PropTypes.func, onClick: PropTypes.func, onDoubleClick: PropTypes.func, pdpPath: PropTypes.func, positions: PropTypes.func, product: PropTypes.object, + productMedia: PropTypes.object, weightClass: PropTypes.func } - handleDoubleClick = (event) => { - if (this.props.onDoubleClick) { - this.props.onDoubleClick(event); + static defaultProps = { + onClick() {}, + onDoubleClick() {}, + productMedia: { + additionalMedia: null, + primaryMedia: null } + }; + + handleDoubleClick = (event) => { + this.props.onDoubleClick(event); } handleClick = (event) => { - if (this.props.onClick) { - this.props.onClick(event); - } + this.props.onClick(event); } renderPinned() { @@ -51,33 +55,32 @@ class ProductGridItems extends Component { } renderMedia() { - if (this.props.media() === false) { - return ( - - ); - } + const { product, productMedia } = this.props; + return ( - + productMedia.primaryMedia} item={product} size="large" mode="span" /> ); } renderAdditionalMedia() { - if (this.props.additionalMedia() !== false) { - if (this.props.isMediumWeight()) { - return ( -
    - {this.props.additionalMedia().map((media) => ( - - ))} - {this.renderOverlay()} -
    - ); - } - } + const { isMediumWeight, productMedia } = this.props; + if (!isMediumWeight()) return null; + + const mediaArray = productMedia.additionalMedia; + if (!mediaArray || mediaArray.length === 0) return null; + + return ( +
    + {mediaArray.map((media) => ( + + ))} + {this.renderOverlay()} +
    + ); } renderNotices() { diff --git a/imports/plugins/included/product-variant/containers/productGridContainer.js b/imports/plugins/included/product-variant/containers/productGridContainer.js index 078f6ab03c5..c388955c4c3 100644 --- a/imports/plugins/included/product-variant/containers/productGridContainer.js +++ b/imports/plugins/included/product-variant/containers/productGridContainer.js @@ -2,10 +2,12 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import update from "immutability-helper"; import _ from "lodash"; -import { Components, registerComponent } from "@reactioncommerce/reaction-components"; +import { compose } from "recompose"; +import { Components, composeWithTracker, registerComponent } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; import { Session } from "meteor/session"; import { Reaction } from "/client/api"; +import { Media } from "/imports/plugins/core/files/client"; import Logger from "/client/modules/logger"; import { ReactionProduct } from "/lib/api"; import ProductGrid from "../components/productGrid"; @@ -44,7 +46,7 @@ const wrapComponent = (Comp) => ( Session.set("productGrid/selectedProducts", _.uniq(selectedProducts)); if (products) { - const filteredProducts = _.filter(products, (product) => _.includes(selectedProducts, product._id)); + const filteredProducts = products.filter((product) => selectedProducts.includes(product._id)); if (Reaction.isPreview() === false) { Reaction.showActionView({ @@ -83,7 +85,7 @@ const wrapComponent = (Comp) => ( const { products } = this; if (products) { - const filteredProducts = _.filter(products, (product) => _.includes(selectedProducts, product._id)); + const filteredProducts = products.filter((product) => selectedProducts.includes(product._id)); Reaction.showActionView({ label: "Grid Settings", @@ -96,26 +98,46 @@ const wrapComponent = (Comp) => ( } handleProductDrag = (dragIndex, hoverIndex) => { - const newState = this.changeProductOrderOnState(dragIndex, hoverIndex); - this.setState(newState, this.callUpdateMethod); - } - - changeProductOrderOnState(dragIndex, hoverIndex) { - const product = this.state.productIds[dragIndex]; - - return update(this.state, { + const tag = ReactionProduct.getTag(); + const dragProductId = this.state.productIds[dragIndex]; + const hoverProductId = this.state.productIds[hoverIndex]; + const dragProductWeight = _.get(this, `state.productsByKey[${dragProductId}].positions[${tag}].weight`, 0); + const dropProductWeight = _.get(this, `state.productsByKey[${hoverProductId}].positions[${tag}].weight`, 0); + + const newState = update(this.state, { + productsByKey: { + [dragProductId]: { + $merge: { + positions: { + [tag]: { + weight: dropProductWeight + } + } + } + }, + [hoverProductId]: { + $merge: { + positions: { + [tag]: { + weight: dragProductWeight + } + } + } + } + }, productIds: { $splice: [ [dragIndex, 1], - [hoverIndex, 0, product] + [hoverIndex, 0, dragProductId] ] } }); + + this.setState(newState); } - callUpdateMethod() { + handleProductDrop = () => { const tag = ReactionProduct.getTag(); - this.state.productIds.map((productId, index) => { const position = { position: index, updatedAt: new Date() }; @@ -142,6 +164,7 @@ const wrapComponent = (Comp) => ( {...this.props} products={this.products} onMove={this.handleProductDrag} + onDrop={this.handleProductDrop} itemSelectHandler={this.handleSelectProductItem} /> @@ -150,6 +173,47 @@ const wrapComponent = (Comp) => ( } ); -registerComponent("ProductGrid", ProductGrid, wrapComponent); - -export default wrapComponent(ProductGrid); +function composer(props, onData) { + // Instantiate an object for use as a map. This object does not inherit prototype or methods from `Object` + const productMediaById = Object.create(null); + (props.products || []).forEach((product) => { + const primaryMedia = Media.findOneLocal({ + "metadata.productId": product._id, + "metadata.toGrid": 1, + "metadata.workflow": { $nin: ["archived", "unpublished"] } + }, { + sort: { "metadata.priority": 1, "uploadedAt": 1 } + }); + + const variantIds = ReactionProduct.getVariants(product._id).map((variant) => variant._id); + let additionalMedia = Media.findLocal({ + "metadata.productId": product._id, + "metadata.variantId": { $in: variantIds }, + "metadata.workflow": { $nin: ["archived", "unpublished"] } + }, { + limit: 3, + sort: { "metadata.priority": 1, "uploadedAt": 1 } + }); + + if (additionalMedia.length < 2) additionalMedia = null; + + productMediaById[product._id] = { + additionalMedia, + primaryMedia + }; + }); + + onData(null, { + productMediaById + }); +} + +registerComponent("ProductGrid", ProductGrid, [ + composeWithTracker(composer), + wrapComponent +]); + +export default compose( + composeWithTracker(composer), + wrapComponent +)(ProductGrid); diff --git a/imports/plugins/included/product-variant/containers/productGridItemsContainer.js b/imports/plugins/included/product-variant/containers/productGridItemsContainer.js index 9f3a9324eee..2cb83595f1f 100644 --- a/imports/plugins/included/product-variant/containers/productGridItemsContainer.js +++ b/imports/plugins/included/product-variant/containers/productGridItemsContainer.js @@ -6,7 +6,6 @@ import { registerComponent } from "@reactioncommerce/reaction-components"; import { Session } from "meteor/session"; import { Reaction } from "/client/api"; import { ReactionProduct } from "/lib/api"; -import { Media } from "/lib/collections"; import { SortableItem } from "/imports/plugins/core/ui/client/containers"; import ProductGridItems from "../components/productGridItems"; @@ -21,22 +20,6 @@ const wrapComponent = (Comp) => ( unmountMe: PropTypes.func } - constructor() { - super(); - - this.productPath = this.productPath.bind(this); - this.positions = this.positions.bind(this); - this.weightClass = this.weightClass.bind(this); - this.isSelected = this.isSelected.bind(this); - this.productMedia = this.productMedia.bind(this); - this.additionalProductMedia = this.additionalProductMedia.bind(this); - this.isMediumWeight = this.isMediumWeight.bind(this); - this.displayPrice = this.displayPrice.bind(this); - this.onDoubleClick = this.onDoubleClick.bind(this); - this.onClick = this.onClick.bind(this); - this.onPageClick = this.onPageClick.bind(this); - } - componentDidMount() { document.querySelector(".page > main").addEventListener("click", this.onPageClick); } @@ -114,31 +97,6 @@ const wrapComponent = (Comp) => ( return false; } - productMedia = () => { - const media = Media.findOne({ - "metadata.productId": this.props.product._id, - "metadata.toGrid": 1 - }, { - sort: { "metadata.priority": 1, "uploadedAt": 1 } - }); - - return media instanceof FS.File ? media : false; - } - - additionalProductMedia = () => { - const variants = ReactionProduct.getVariants(this.props.product._id); - const variantIds = variants.map((variant) => variant._id); - const mediaArray = Media.find({ - "metadata.productId": this.props.product._id, - "metadata.variantId": { - $in: variantIds - }, - "metadata.workflow": { $nin: ["archived", "unpublished"] } - }, { limit: 3 }); - - return mediaArray.count() > 1 ? mediaArray : false; - } - isMediumWeight = () => { const positions = this.positions(); const weight = positions.weight || 0; @@ -258,8 +216,6 @@ const wrapComponent = (Comp) => ( positions={this.positions} weightClass={this.weightClass} isSelected={this.isSelected} - media={this.productMedia} - additionalMedia={this.additionalProductMedia} isMediumWeight={this.isMediumWeight} displayPrice={this.displayPrice} onDoubleClick={this.onDoubleClick} diff --git a/imports/plugins/included/product-variant/containers/productsContainer.js b/imports/plugins/included/product-variant/containers/productsContainer.js index ddfe54d834c..5ce0ce1e184 100644 --- a/imports/plugins/included/product-variant/containers/productsContainer.js +++ b/imports/plugins/included/product-variant/containers/productsContainer.js @@ -1,200 +1,33 @@ -import React, { Component } from "react"; +import React from "react"; import PropTypes from "prop-types"; import { compose } from "recompose"; import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; import { Meteor } from "meteor/meteor"; -import { Session } from "meteor/session"; import { Reaction } from "/client/api"; -import { ITEMS_INCREMENT } from "/client/config/defaults"; -import { ReactionProduct } from "/lib/api"; -import { applyProductRevision } from "/lib/api/products"; -import { Products, Tags, Shops } from "/lib/collections"; -import ProductsComponent from "../components/products"; +import ProductsContainerAdmin from "./productsContainerAdmin.js"; +import ProductsContainerCustomer from "./productsContainerCustomer.js"; -/** - * loadMoreProducts - * @summary whenever #productScrollLimitLoader becomes visible, retrieve more results - * this basically runs this: - * Session.set('productScrollLimit', Session.get('productScrollLimit') + ITEMS_INCREMENT); - * @return {undefined} - */ -function loadMoreProducts() { - let threshold; - const target = document.querySelectorAll("#productScrollLimitLoader"); - let scrollContainer = document.querySelectorAll("#container-main"); - if (scrollContainer.length === 0) { - scrollContainer = window; +const ProductsContainer = ({ isAdmin }) => { + if (isAdmin) { + return ; } + return ; +}; - if (target.length) { - threshold = scrollContainer[0].scrollHeight - scrollContainer[0].scrollTop === scrollContainer[0].clientHeight; - - if (threshold) { - if (!target[0].getAttribute("visible")) { - target[0].setAttribute("productScrollLimit", true); - Session.set("productScrollLimit", Session.get("productScrollLimit") + ITEMS_INCREMENT || 24); - } - } else if (target[0].getAttribute("visible")) { - target[0].setAttribute("visible", false); - } - } -} - -const wrapComponent = (Comp) => ( - class ProductsContainer extends Component { - static propTypes = { - canLoadMoreProducts: PropTypes.bool, - productsSubscription: PropTypes.object, - showNotFound: PropTypes.bool - }; - - constructor(props) { - super(props); - this.state = { - initialLoad: true - }; - - this.ready = this.ready.bind(this); - this.loadMoreProducts = this.loadMoreProducts.bind(this); - } - - ready = () => { - if (this.props.showNotFound === true) { - return false; - } - - const isInitialLoad = this.state.initialLoad === true; - const isReady = this.props.productsSubscription.ready(); - - if (isInitialLoad === false) { - return true; - } - - if (isReady) { - return true; - } - - return false; - } - - loadMoreProducts = () => this.props.canLoadMoreProducts === true - - loadProducts = (event) => { - event.preventDefault(); - this.setState({ - initialLoad: false - }); - loadMoreProducts(); - } - - render() { - return ( - - ); - } - } -); +ProductsContainer.propTypes = { + isAdmin: PropTypes.bool +}; function composer(props, onData) { - window.prerenderReady = false; - - let canLoadMoreProducts = false; - - const slug = Reaction.Router.getParam("slug"); - const shopIdOrSlug = Reaction.Router.getParam("shopSlug"); - - const tag = Tags.findOne({ slug }) || Tags.findOne(slug); - const scrollLimit = Session.get("productScrollLimit"); - let tags = {}; // this could be shop default implementation needed - let shopIds = {}; - - if (tag) { - tags = { tags: [tag._id] }; - } - - if (shopIdOrSlug) { - shopIds = { shops: [shopIdOrSlug] }; - } - - // if we get an invalid slug, don't return all products - if (!tag && slug) { - onData(null, { - showNotFound: true - }); - - return; - } - - const currentTag = ReactionProduct.getTag(); - - const sort = { - [`positions.${currentTag}.position`]: 1, - [`positions.${currentTag}.createdAt`]: 1, - createdAt: 1 - }; - - const viewAsPref = Reaction.getUserPreferences("reaction-dashboard", "viewAs"); - - // Edit mode is true by default - let editMode = true; - - // if we have a "viewAs" preference and the preference is not set to "administrator", then edit mode is false - if (viewAsPref && viewAsPref !== "administrator") { - editMode = false; - } - - const queryParams = Object.assign({}, tags, Reaction.Router.current().query, shopIds); - const productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode); - - if (productsSubscription.ready()) { - window.prerenderReady = true; - } - - const activeShopsIds = Shops.find({ - $or: [ - { "workflow.status": "active" }, - { _id: Reaction.getPrimaryShopId() } - ] - }).fetch().map((activeShop) => activeShop._id); - - const productCursor = Products.find({ - ancestors: [], - type: { $in: ["simple"] }, - shopId: { $in: activeShopsIds } - }); - - const products = productCursor.map((product) => applyProductRevision(product)); - - const sortedProducts = ReactionProduct.sortProducts(products, currentTag); - - canLoadMoreProducts = productCursor.count() >= Session.get("productScrollLimit"); - const stateProducts = sortedProducts; - - Session.set("productGrid/products", sortedProducts); - - const isActionViewOpen = Reaction.isActionViewOpen(); - if (isActionViewOpen === false) { - Session.set("productGrid/selectedProducts", []); - } + const isAdmin = Reaction.hasPermission("createProduct", Meteor.userId()); onData(null, { - productsSubscription, - products: stateProducts, - canLoadMoreProducts + isAdmin }); } -registerComponent("Products", ProductsComponent, [ - composeWithTracker(composer), - wrapComponent +registerComponent("Products", ProductsContainer, [ + composeWithTracker(composer) ]); -export default compose( - composeWithTracker(composer), - wrapComponent -)(ProductsComponent); +export default compose(composeWithTracker(composer))(ProductsContainer); diff --git a/imports/plugins/included/product-variant/containers/productsContainerAdmin.js b/imports/plugins/included/product-variant/containers/productsContainerAdmin.js new file mode 100644 index 00000000000..5b7136075a7 --- /dev/null +++ b/imports/plugins/included/product-variant/containers/productsContainerAdmin.js @@ -0,0 +1,215 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { compose } from "recompose"; +import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; +import { Meteor } from "meteor/meteor"; +import { ReactiveVar } from "meteor/reactive-var"; +import { Session } from "meteor/session"; +import { Tracker } from "meteor/tracker"; +import { Reaction } from "/client/api"; +import { ITEMS_INCREMENT } from "/client/config/defaults"; +import { ReactionProduct } from "/lib/api"; +import { applyProductRevision } from "/lib/api/products"; +import { Products, Tags, Shops } from "/lib/collections"; +import ProductsComponent from "../components/products"; + +const reactiveProductIds = new ReactiveVar([], (oldVal, newVal) => JSON.stringify(oldVal.sort()) === JSON.stringify(newVal.sort())); + +// Isolated resubscribe to product grid images, only when the list of product IDs changes +Tracker.autorun(() => { + Meteor.subscribe("ProductGridMedia", reactiveProductIds.get()); +}); + + +/** + * loadMoreProducts + * @summary whenever #productScrollLimitLoader becomes visible, retrieve more results + * this basically runs this: + * Session.set('productScrollLimit', Session.get('productScrollLimit') + ITEMS_INCREMENT); + * @return {undefined} + */ +function loadMoreProducts() { + let threshold; + const target = document.querySelectorAll("#productScrollLimitLoader"); + let scrollContainer = document.querySelectorAll("#container-main"); + if (scrollContainer.length === 0) { + scrollContainer = window; + } + + if (target.length) { + threshold = scrollContainer[0].scrollHeight - scrollContainer[0].scrollTop === scrollContainer[0].clientHeight; + + if (threshold) { + if (!target[0].getAttribute("visible")) { + target[0].setAttribute("productScrollLimit", true); + Session.set("productScrollLimit", Session.get("productScrollLimit") + ITEMS_INCREMENT || 24); + } + } else if (target[0].getAttribute("visible")) { + target[0].setAttribute("visible", false); + } + } +} + +const wrapComponent = (Comp) => ( + class ProductsContainer extends Component { + static propTypes = { + canLoadMoreProducts: PropTypes.bool, + productsSubscription: PropTypes.object, + showNotFound: PropTypes.bool + }; + + constructor(props) { + super(props); + this.state = { + initialLoad: true + }; + + this.ready = this.ready.bind(this); + this.loadMoreProducts = this.loadMoreProducts.bind(this); + } + + ready = () => { + if (this.props.showNotFound === true) { + return false; + } + + const isInitialLoad = this.state.initialLoad === true; + const isReady = this.props.productsSubscription.ready(); + + if (isInitialLoad === false) { + return true; + } + + if (isReady) { + return true; + } + + return false; + } + + loadMoreProducts = () => this.props.canLoadMoreProducts === true + + loadProducts = (event) => { + event.preventDefault(); + this.setState({ + initialLoad: false + }); + loadMoreProducts(); + } + + render() { + return ( + + ); + } + } +); + +function composer(props, onData) { + window.prerenderReady = false; + + let canLoadMoreProducts = false; + + const slug = Reaction.Router.getParam("slug"); + const shopIdOrSlug = Reaction.Router.getParam("shopSlug"); + + const tag = Tags.findOne({ slug }) || Tags.findOne(slug); + const scrollLimit = Session.get("productScrollLimit"); + let tags = {}; // this could be shop default implementation needed + let shopIds = {}; + + if (tag) { + tags = { tags: [tag._id] }; + } + + if (shopIdOrSlug) { + shopIds = { shops: [shopIdOrSlug] }; + } + + // if we get an invalid slug, don't return all products + if (!tag && slug) { + onData(null, { + showNotFound: true + }); + + return; + } + + const currentTag = ReactionProduct.getTag(); + + const sort = { + [`positions.${currentTag}.position`]: 1, + [`positions.${currentTag}.createdAt`]: 1, + createdAt: 1 + }; + + const viewAsPref = Reaction.getUserPreferences("reaction-dashboard", "viewAs"); + + // Edit mode is true by default + let editMode = true; + + // if we have a "viewAs" preference and the preference is not set to "administrator", then edit mode is false + if (viewAsPref && viewAsPref !== "administrator") { + editMode = false; + } + + const queryParams = Object.assign({}, tags, Reaction.Router.current().query, shopIds); + const productsSubscription = Meteor.subscribe("Products", scrollLimit, queryParams, sort, editMode); + + if (productsSubscription.ready()) { + window.prerenderReady = true; + } + + const activeShopsIds = Shops.find({ + $or: [ + { "workflow.status": "active" }, + { _id: Reaction.getPrimaryShopId() } + ] + }).map((activeShop) => activeShop._id); + + const productCursor = Products.find({ + ancestors: [], + type: { $in: ["simple"] }, + shopId: { $in: activeShopsIds } + }); + + const productIds = []; + const products = productCursor.map((product) => { + productIds.push(product._id); + + return applyProductRevision(product); + }); + + const sortedProducts = ReactionProduct.sortProducts(products, currentTag); + Session.set("productGrid/products", sortedProducts); + + reactiveProductIds.set(productIds); + + canLoadMoreProducts = productCursor.count() >= Session.get("productScrollLimit"); + + const isActionViewOpen = Reaction.isActionViewOpen(); + if (isActionViewOpen === false) { + Session.set("productGrid/selectedProducts", []); + } + + onData(null, { + canLoadMoreProducts, + products: sortedProducts, + productsSubscription + }); +} + +registerComponent("ProductsAdmin", ProductsComponent, [ + composeWithTracker(composer), + wrapComponent +]); + +export default compose( + composeWithTracker(composer), + wrapComponent +)(ProductsComponent); diff --git a/imports/plugins/included/product-variant/containers/productsContainerCustomer.js b/imports/plugins/included/product-variant/containers/productsContainerCustomer.js new file mode 100644 index 00000000000..b01bfa566a9 --- /dev/null +++ b/imports/plugins/included/product-variant/containers/productsContainerCustomer.js @@ -0,0 +1,124 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { compose } from "recompose"; +import { registerComponent, composeWithTracker } from "@reactioncommerce/reaction-components"; +import { Meteor } from "meteor/meteor"; +import { Session } from "meteor/session"; +import { Reaction } from "/client/api"; +import { ITEMS_INCREMENT } from "/client/config/defaults"; +import { ReactionProduct } from "/lib/api"; +import { Catalog, Tags, Shops } from "/lib/collections"; +import ProductsComponent from "../components/customer/productGrid"; + +const wrapComponent = (Comp) => ( + class ProductsContainer extends Component { + static propTypes = { + canLoadMoreProducts: PropTypes.bool, + productsSubscription: PropTypes.object, + showNotFound: PropTypes.bool + }; + + constructor(props) { + super(props); + this.state = { + initialLoad: true + }; + } + + loadProducts = () => { + this.setState({ + initialLoad: false + }); + // load in the next set of products + Session.set("productScrollLimit", Session.get("productScrollLimit") + ITEMS_INCREMENT || 24); + } + + render() { + return ( + + ); + } + } +); + +function composer(props, onData) { + window.prerenderReady = false; + + let canLoadMoreProducts = false; + + const slug = Reaction.Router.getParam("slug"); + const shopIdOrSlug = Reaction.Router.getParam("shopSlug"); + + const tag = Tags.findOne({ slug }) || Tags.findOne(slug); + const scrollLimit = Session.get("productScrollLimit"); + let tags = {}; // this could be shop default implementation needed + let shopIds = {}; + + if (tag) { + tags = { tags: [tag._id] }; + } + + if (shopIdOrSlug) { + shopIds = { shops: [shopIdOrSlug] }; + } + + // if we get an invalid slug, don't return all products + if (!tag && slug) { + onData(null, { + showNotFound: true + }); + + return; + } + + const currentTag = ReactionProduct.getTag(); + + const sort = { + [`positions.${currentTag}.position`]: 1, + [`positions.${currentTag}.createdAt`]: 1, + createdAt: 1 + }; + + const queryParams = Object.assign({}, tags, Reaction.Router.current().query, shopIds); + const productsSubscription = Meteor.subscribe("Products/grid", scrollLimit, queryParams, sort); + + if (productsSubscription.ready()) { + window.prerenderReady = true; + } + + const activeShopsIds = Shops.find({ + $or: [ + { "workflow.status": "active" }, + { _id: Reaction.getPrimaryShopId() } + ] + }).map((activeShop) => activeShop._id); + + const productCursor = Catalog.find({ + ancestors: [], + type: { $in: ["product-simple"] }, + shopId: { $in: activeShopsIds } + }, { + $sort: sort + }); + + canLoadMoreProducts = productCursor.count() >= Session.get("productScrollLimit"); + + const products = productCursor.fetch(); + onData(null, { + canLoadMoreProducts, + products, + productsSubscription + }); +} + +registerComponent("ProductsCustomer", ProductsComponent, [ + composeWithTracker(composer) +]); + +export default compose( + composeWithTracker(composer), + wrapComponent +)(ProductsComponent); diff --git a/imports/plugins/included/search-mongo/client/settings/search.js b/imports/plugins/included/search-mongo/client/settings/search.js index 2f4be419cc4..c3cc8a4a743 100644 --- a/imports/plugins/included/search-mongo/client/settings/search.js +++ b/imports/plugins/included/search-mongo/client/settings/search.js @@ -26,18 +26,13 @@ Template.searchSettings.events({ AutoForm.hooks({ "search-update-form": { - /* eslint-disable no-unused-vars*/ onSuccess() { Alerts.removeSeen(); - return Alerts.toast( - i18next.t("searchSettings.settingsSaved"), - "success" - ); + return Alerts.toast(i18next.t("searchSettings.settingsSaved"), "success"); }, onError(operation, error) { Alerts.removeSeen(); return Alerts.toast(`${i18next.t("searchSettings.settingsFailed")} ${error}`, "error"); } - /* eslint-enable no-unused-vars*/ } }); diff --git a/imports/plugins/included/search-mongo/lib/collections/schemas/search.js b/imports/plugins/included/search-mongo/lib/collections/schemas/search.js index 882acfe1c69..37262023357 100644 --- a/imports/plugins/included/search-mongo/lib/collections/schemas/search.js +++ b/imports/plugins/included/search-mongo/lib/collections/schemas/search.js @@ -1,70 +1,90 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; -export const SearchPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.products.includes.title": { - type: Boolean, - defaultValue: true, - label: "Include title" - }, - "settings.products.weights.title": { - type: Number, - label: "Title weight", - defaultValue: 10, - max: 10, - min: 1 - }, - "settings.products.includes.description": { - type: Boolean, - label: "Include description", - defaultValue: true - }, - "settings.products.weights.description": { - type: Number, - label: "Desc. weight", - defaultValue: 5, - max: 10, - min: 1 - }, - "settings.products.includes.pageTitle": { - type: Boolean, - label: "Include page title", - defaultValue: false - }, - "settings.products.weights.pageTitle": { - type: Number, - label: "Page title weight", - defaultValue: 2, - max: 10, - min: 1 - }, - "settings.products.includes.metafields": { - type: Boolean, - label: "Include details", - defaultValue: true - }, - "settings.products.weights.metafields": { - type: Number, - label: "Details weight", - defaultValue: 6, - max: 10, - min: 1 - }, - "settings.products.includes.vendor": { - type: Boolean, - label: "Include vendor", - defaultValue: true - }, - "settings.products.weights.vendor": { - type: Number, - label: "Vendor weight", - defaultValue: 6, - max: 10, - min: 1 - } +export const SearchPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.products": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.products.includes": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.products.weights": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.products.includes.title": { + type: Boolean, + defaultValue: true, + label: "Include title" + }, + "settings.products.weights.title": { + type: SimpleSchema.Integer, + label: "Title weight", + defaultValue: 10, + max: 10, + min: 1 + }, + "settings.products.includes.description": { + type: Boolean, + label: "Include description", + defaultValue: true + }, + "settings.products.weights.description": { + type: SimpleSchema.Integer, + label: "Desc. weight", + defaultValue: 5, + max: 10, + min: 1 + }, + "settings.products.includes.pageTitle": { + type: Boolean, + label: "Include page title", + defaultValue: false + }, + "settings.products.weights.pageTitle": { + type: SimpleSchema.Integer, + label: "Page title weight", + defaultValue: 2, + max: 10, + min: 1 + }, + "settings.products.includes.metafields": { + type: Boolean, + label: "Include details", + defaultValue: true + }, + "settings.products.weights.metafields": { + type: SimpleSchema.Integer, + label: "Details weight", + defaultValue: 6, + max: 10, + min: 1 + }, + "settings.products.includes.vendor": { + type: Boolean, + label: "Include vendor", + defaultValue: true + }, + "settings.products.weights.vendor": { + type: SimpleSchema.Integer, + label: "Vendor weight", + defaultValue: 6, + max: 10, + min: 1 } -]); +}); registerSchema("SearchPackageConfig", SearchPackageConfig); diff --git a/imports/plugins/included/search-mongo/server/hooks/search.js b/imports/plugins/included/search-mongo/server/hooks/search.js index e065afbe5e8..a5f872bc7c3 100644 --- a/imports/plugins/included/search-mongo/server/hooks/search.js +++ b/imports/plugins/included/search-mongo/server/hooks/search.js @@ -11,7 +11,9 @@ import { Hooks, Logger } from "/server/api"; Hooks.Events.add("afterAccountsInsert", (userId, accountId) => { if (AccountSearch && !Meteor.isAppTest) { - buildAccountSearchRecord(accountId); + // Passing forceIndex will run account search index even if + // updated fields don't match a searchable field + buildAccountSearchRecord(accountId, ["forceIndex"]); } }); @@ -21,10 +23,11 @@ Hooks.Events.add("afterAccountsRemove", (userId, accountId) => { } }); -Hooks.Events.add("afterAccountsUpdate", (userId, accountId) => { +Hooks.Events.add("afterAccountsUpdate", (userId, updateData) => { + const { accountId, updatedFields } = updateData; + if (AccountSearch && !Meteor.isAppTest) { - AccountSearch.remove(accountId); - buildAccountSearchRecord(accountId); + buildAccountSearchRecord(accountId, updatedFields); } }); diff --git a/imports/plugins/included/search-mongo/server/methods/formHandler.js b/imports/plugins/included/search-mongo/server/methods/formHandler.js index 872fe5e2762..f8f80321821 100644 --- a/imports/plugins/included/search-mongo/server/methods/formHandler.js +++ b/imports/plugins/included/search-mongo/server/methods/formHandler.js @@ -3,10 +3,9 @@ import { Meteor } from "meteor/meteor"; import { check, Match } from "meteor/check"; import { Job } from "/imports/plugins/core/job-collection/lib"; import { Packages, Jobs } from "/lib/collections"; -import { CorePackageConfig } from "/lib/collections/schemas"; +import { SearchPackageConfig } from "../../lib/collections/schemas"; import { Logger, Reaction } from "/server/api"; - function fieldsChanged(changedFields, fieldType = "includes") { for (const field of changedFields) { if (field.indexOf(fieldType) !== -1) { @@ -21,10 +20,24 @@ function weightsChanged(changedFields) { } Meteor.methods({ - "search/updateSearchSettings"(modifier, _id) { - check(modifier, Match.Optional(CorePackageConfig)); - check(_id, String); - const currentSettings = Packages.findOne(_id); + /** + * @name search/updateSearchSettings + * @method + * @param {Object} details An object with _id and modifier props + * @param {String} [docId] DEPRECATED. The _id, if details is the modifier. + */ + "search/updateSearchSettings"(details, docId) { + check(details, Object); + + // Backward compatibility + check(docId, Match.Optional(String)); + const id = docId || details._id; + const modifier = docId ? details : details.modifier; + + check(id, String); + SearchPackageConfig.validate(modifier, { modifier: true }); + + const currentSettings = Packages.findOne(id); const newSettingsArray = _.keys(modifier.$set); const changedSettings = []; for (const setting of newSettingsArray) { @@ -69,7 +82,7 @@ Meteor.methods({ cancelRepeats: true }); } - Packages.update(_id, modifier); + Packages.update(id, modifier); return rebuildJob; } }); diff --git a/imports/plugins/included/search-mongo/server/methods/searchcollections.js b/imports/plugins/included/search-mongo/server/methods/searchcollections.js index c55a54b90f1..78f6a931be1 100644 --- a/imports/plugins/included/search-mongo/server/methods/searchcollections.js +++ b/imports/plugins/included/search-mongo/server/methods/searchcollections.js @@ -242,7 +242,11 @@ export function buildOrderSearchRecord(orderId) { orderSearch.variants.title = order.items.map((item) => item.variants && item.variants.title); orderSearch.variants.optionTitle = order.items.map((item) => item.variants && item.variants.optionTitle); - OrderSearch.insert(orderSearch); + try { + OrderSearch.upsert(orderId, { $set: { ...orderSearch } }); + } catch (error) { + Logger.error(error, "Failed to add order to the OrderSearch collection"); + } } export function buildOrderSearch(cb) { @@ -261,13 +265,47 @@ export function buildOrderSearch(cb) { } } +export function buildAccountSearchRecord(accountId, updatedFields) { + Logger.debug("building account search record"); + check(accountId, String); + check(updatedFields, Array); + + const account = Accounts.findOne(accountId); + // let's ignore anonymous accounts + if (account && account.emails && account.emails.length) { + const accountSearch = {}; + + // Not all required fields are used in search + // We need to filter through fields that are used, + // and only update the search index if one of those fields were updated + // forceIndex is included to forceIndexing on startup, or when manually added + const searchableFields = ["forceIndex", "shopId", "emails", "firstName", "lastName", "phone"]; + + const shouldRunIndex = updatedFields && updatedFields.some((field) => searchableFields.includes(field)); + + // If updatedFields contains one of the searchableFields, run the indexing + if (shouldRunIndex) { + AccountSearch.remove(accountId); + for (const field of requiredFields.accounts) { + if (transformations.accounts[field]) { + accountSearch[field] = transformations.accounts[field](account[field]); + } else { + accountSearch[field] = account[field]; + } + } + AccountSearch.insert(accountSearch); + } + } +} export function buildAccountSearch(cb) { check(cb, Match.Optional(Function)); AccountSearch.remove({}); const accounts = Accounts.find({}).fetch(); for (const account of accounts) { - buildAccountSearchRecord(account._id); + // Passing forceIndex will run account search index even if + // updated fields don't match a searchable field + buildAccountSearchRecord(account._id, ["forceIndex"]); } const rawAccountSearchCollection = AccountSearch.rawCollection(); rawAccountSearchCollection.dropIndexes().catch(handleIndexUpdateFailures); @@ -276,23 +314,3 @@ export function buildAccountSearch(cb) { cb(); } } - -export function buildAccountSearchRecord(accountId) { - Logger.debug("building account search record"); - check(accountId, String); - const account = Accounts.findOne(accountId); - // let's ignore anonymous accounts - if (account && account.emails && account.emails.length) { - const accountSearch = {}; - for (const field of requiredFields.accounts) { - if (transformations.accounts[field]) { - accountSearch[field] = transformations.accounts[field](account[field]); - } else { - accountSearch[field] = account[field]; - } - } - AccountSearch.insert(accountSearch); - const rawAccountSearchCollection = AccountSearch.rawCollection(); - rawAccountSearchCollection.createIndex({ shopId: 1, emails: 1 }).catch(handleIndexUpdateFailures); - } -} diff --git a/imports/plugins/included/search-mongo/server/publications/searchresults.app-test.js b/imports/plugins/included/search-mongo/server/publications/searchresults.app-test.js index 5758bdf6fd3..d94c32ff8b9 100644 --- a/imports/plugins/included/search-mongo/server/publications/searchresults.app-test.js +++ b/imports/plugins/included/search-mongo/server/publications/searchresults.app-test.js @@ -129,7 +129,9 @@ describe("Account Search results", function () { beforeEach(function () { sandbox = sinon.sandbox.create(); account = createAccount(); - buildAccountSearchRecord(account._id); + // Passing forceIndex will run account search index even if + // updated fields don't match a searchable field + buildAccountSearchRecord(account._id, ["forceIndex"]); }); afterEach(function () { diff --git a/imports/plugins/included/shipping-rates/server/hooks/hooks.js b/imports/plugins/included/shipping-rates/server/hooks/hooks.js index 41dcda2d9e0..2709bdff428 100644 --- a/imports/plugins/included/shipping-rates/server/hooks/hooks.js +++ b/imports/plugins/included/shipping-rates/server/hooks/hooks.js @@ -1,4 +1,3 @@ -import { check } from "meteor/check"; import { Meteor } from "meteor/meteor"; import { Shipping, Packages } from "/lib/collections"; import { Logger, Reaction, Hooks } from "/server/api"; @@ -17,7 +16,7 @@ import { Cart as CartSchema } from "/lib/collections/schemas"; * shipping rates. */ function getShippingRates(previousQueryResults, cart) { - check(cart, CartSchema); + CartSchema.validate(cart); const [rates, retrialTargets] = previousQueryResults; const shops = []; const products = cart.items; diff --git a/imports/plugins/included/shipping-rates/server/methods/rates.js b/imports/plugins/included/shipping-rates/server/methods/rates.js index c3ce43569af..d386a3f3315 100644 --- a/imports/plugins/included/shipping-rates/server/methods/rates.js +++ b/imports/plugins/included/shipping-rates/server/methods/rates.js @@ -64,7 +64,7 @@ export const methods = { * @return { Number } update result */ "shipping/rates/update"(method) { - check(method, ShippingMethod); + ShippingMethod.validate(method); if (!Reaction.hasPermission(shippingRoles)) { throw new Meteor.Error("access-denied", "Access Denied"); } @@ -92,13 +92,22 @@ export const methods = { throw new Meteor.Error("access-denied", "Access Denied"); } - return Shipping.update({ + const rates = Shipping.findOne({ "methods._id": rateId }); + const { methods: shippingMethods } = rates; + const updatedMethods = shippingMethods.filter((method) => method._id !== rateId); + + // HACK: not sure why we need to do this.. but it works. + // Replaced a $pull which in theory is better, but was broken. + // Issue w/ pull was introduced during the simpl-schema update + const deleted = Shipping.update({ "methods._id": rateId }, { - $pull: { - methods: { _id: rateId } + $set: { + methods: updatedMethods } }); + + return deleted; } }; diff --git a/imports/plugins/included/shipping-shippo/lib/collections/schemas/shippo.js b/imports/plugins/included/shipping-shippo/lib/collections/schemas/shippo.js index 3df3726b173..0529d369457 100644 --- a/imports/plugins/included/shipping-shippo/lib/collections/schemas/shippo.js +++ b/imports/plugins/included/shipping-shippo/lib/collections/schemas/shippo.js @@ -1,16 +1,20 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { PackageConfig } from "/lib/collections/schemas/registry"; import { registerSchema } from "@reactioncommerce/reaction-collections"; -export const ShippoPackageConfig = new SimpleSchema([ - PackageConfig, { - "settings.apiKey": { - type: String, - label: "API Key", - min: 10, - optional: true - } +export const ShippoPackageConfig = PackageConfig.clone().extend({ + // Remove blackbox: true from settings obj + "settings": { + type: Object, + optional: true, + blackbox: false, + defaultValue: {} + }, + "settings.apiKey": { + type: String, + label: "API Key", + min: 10, + optional: true } -]); +}); registerSchema("ShippoPackageConfig", ShippoPackageConfig); diff --git a/imports/plugins/included/shipping-shippo/server/hooks/rates.js b/imports/plugins/included/shipping-shippo/server/hooks/rates.js index bb41bc8a565..9f86226e63b 100644 --- a/imports/plugins/included/shipping-shippo/server/hooks/rates.js +++ b/imports/plugins/included/shipping-shippo/server/hooks/rates.js @@ -1,3 +1,4 @@ +import _ from "lodash"; import { Meteor } from "meteor/meteor"; import { Shipping, Packages } from "/lib/collections"; import { Logger, Reaction, Hooks } from "/server/api"; @@ -5,39 +6,28 @@ import { Logger, Reaction, Hooks } from "/server/api"; // callback ran on getShippingRates hook function getShippingRates(previousQueryResults, cart) { const marketplaceSettings = Reaction.getMarketplaceSettings(); - let merchantShippingRates = false; - if (marketplaceSettings && marketplaceSettings.public && marketplaceSettings.public.merchantShippingRates) { - ({ merchantShippingRates } = marketplaceSettings.public); - } - - const [rates, retrialTargets] = previousQueryResults; - const shops = []; - const products = cart.items; + const merchantShippingRates = _.get(marketplaceSettings, "public.merchantShippingRates", false); - let pkgData; if (merchantShippingRates) { Logger.fatal("Multiple shipping providers is currently not implemented"); throw new Meteor.Error("not-implemented", "Multiple shipping providers is currently not implemented"); - } else { - pkgData = Packages.findOne({ - name: "reaction-shippo", - shopId: Reaction.getPrimaryShopId() - }); } + const pkgData = Packages.findOne({ + name: "reaction-shippo", + shopId: Reaction.getPrimaryShopId() + }); + + const [rates, retrialTargets] = previousQueryResults; + const products = cart.items; // must have cart items and package enabled to calculate shipping - if (!pkgData || !cart.items || pkgData.settings.shippo.enabled !== true) { + if (!pkgData || !products || pkgData.settings.shippo.enabled !== true) { return [rates, retrialTargets]; } - // default selector is current shop - let selector = { - "shopId": Reaction.getShopId(), - "provider.enabled": true - }; - // if we don't have merchant shipping rates enabled, only grab rates from primary shop + const shops = []; if (!merchantShippingRates) { shops.push(Reaction.getPrimaryShopId()); } else { @@ -49,32 +39,26 @@ function getShippingRates(previousQueryResults, cart) { } } } - selector = { - "shopId": { - $in: shops - }, - "provider.enabled": true - }; - const shippingCollection = Shipping.find(selector); const shippoDocs = {}; - if (shippingCollection) { - shippingCollection.forEach((doc) => { - // If provider is from Shippo, put it in an object to get rates dynamically(shippoApi) for all of them after. - if (doc.provider.shippoProvider) { - shippoDocs[doc.provider.shippoProvider.carrierAccountId] = doc; - } - }); - - // Get shippingRates from Shippo - if (Object.keys(shippoDocs).length > 0) { - const targets = retrialTargets.slice(); - const shippingRatesInfo = - Meteor.call("shippo/getShippingRatesForCart", cart._id, shippoDocs, targets); - const [shippoRates, singleRetrialTarget] = shippingRatesInfo; - rates.push(...shippoRates); - retrialTargets.push(...singleRetrialTarget); + Shipping.find({ + "shopId": { $in: shops }, + "provider.enabled": true + }).forEach((doc) => { + // If provider is from Shippo, put it in an object to get rates dynamically(shippoApi) for all of them after. + if (doc.provider.shippoProvider) { + shippoDocs[doc.provider.shippoProvider.carrierAccountId] = doc; } + }); + + // Get shippingRates from Shippo + if (Object.keys(shippoDocs).length > 0) { + const targets = retrialTargets.slice(); + const shippingRatesInfo = + Meteor.call("shippo/getShippingRatesForCart", cart._id, shippoDocs, targets); + const [shippoRates, singleRetrialTarget] = shippingRatesInfo; + rates.push(...shippoRates); + retrialTargets.push(...singleRetrialTarget); } Logger.debug("Shippo onGetShippingRates", [rates, retrialTargets]); diff --git a/imports/plugins/included/shipping-shippo/server/lib/shippoApiSchema.js b/imports/plugins/included/shipping-shippo/server/lib/shippoApiSchema.js index 420e425e272..79f90df7c16 100644 --- a/imports/plugins/included/shipping-shippo/server/lib/shippoApiSchema.js +++ b/imports/plugins/included/shipping-shippo/server/lib/shippoApiSchema.js @@ -1,8 +1,9 @@ /* eslint camelcase: 0 */ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; +import { check } from "meteor/check"; +import { Tracker } from "meteor/tracker"; import { registerSchema } from "@reactioncommerce/reaction-collections"; - export const addressSchema = new SimpleSchema({ object_purpose: { type: String, allowedValues: ["QUOTE", "PURCHASE"] }, name: { type: String, optional: true }, @@ -19,35 +20,39 @@ export const addressSchema = new SimpleSchema({ is_residential: { type: Boolean, optional: true }, validate: { type: Boolean, optional: true }, metadata: { type: String, optional: true } -}); +}, { check, tracker: Tracker }); registerSchema("addressSchema", addressSchema); // Overrides the properties required for purchasing labels/shipping. // we don't override the purpose because for some cases like getRatesForCart we don't want to // purchase Labels(purpose="QUOTE" but we want all the fields required for purchasing to be present. -export const purchaseAddressSchema = new SimpleSchema([addressSchema, { - name: { type: String, optional: false }, - street1: { type: String, optional: false }, - city: { type: String, optional: false }, - state: { type: String, optional: false }, - zip: { type: String, optional: false }, - phone: { type: String, optional: false }, - email: { type: String, regEx: SimpleSchema.RegEx.Email, optional: true } -}]); +export const purchaseAddressSchema = addressSchema.clone().extend({ + name: String, + street1: String, + city: String, + state: String, + zip: String, + phone: String, + email: { + type: String, + regEx: SimpleSchema.RegEx.Email, + optional: true + } +}); registerSchema("purchaseAddressSchema", purchaseAddressSchema); export const parcelSchema = new SimpleSchema({ - length: { type: Number, decimal: true, min: 0.0001 }, - width: { type: Number, decimal: true, min: 0.0001 }, - height: { type: Number, decimal: true, min: 0.0001 }, + length: { type: Number, min: 0.0001 }, + width: { type: Number, min: 0.0001 }, + height: { type: Number, min: 0.0001 }, distance_unit: { type: String, allowedValues: ["cm", "in", "ft", "mm", "m", "yd"] }, - weight: { type: Number, decimal: true, min: 0.0001 }, + weight: { type: Number, min: 0.0001 }, mass_unit: { type: String, allowedValues: ["g", "oz", "lb", "kg"] }, template: { type: String, optional: true }, extra: { type: Object, optional: true }, metadata: { type: String, optional: true } -}); +}, { check, tracker: Tracker }); registerSchema("parcelSchema", parcelSchema); diff --git a/imports/plugins/included/shipping-shippo/server/methods/shippo.js b/imports/plugins/included/shipping-shippo/server/methods/shippo.js index 29cf7f18844..60ccc42ca55 100644 --- a/imports/plugins/included/shipping-shippo/server/methods/shippo.js +++ b/imports/plugins/included/shipping-shippo/server/methods/shippo.js @@ -57,15 +57,14 @@ function getTotalCartweight(cart) { function ratesParser(shippoRates, shippoDocs) { return shippoRates.map((rate) => { const rateAmount = parseFloat(rate.amount); - // const methodLabel = `${rate.provider} - ${rate.servicelevel_name}`; const reactionRate = { carrier: rate.provider, method: { + carrier: rate.provider, enabled: true, + handling: 0, label: rate.servicelevel_name, rate: rateAmount, - handling: 0, - carrier: rate.provider, settings: { // carrierAccount: rate.carrier_account, rateId: rate.object_id, @@ -196,26 +195,30 @@ export const methods = { * Also inserts(and deletes if already exist) docs in the Shipping collection each of the * activated Carriers of the Shippo account. * This method is intended to be used mainly by Autoform. - * @param {Object} modifier - The Autoform's modifier string - * @param {String} _id - The id of the Shippo package that gets updated - * @return {Object} result - The object returned. + * @param {Object} details An object with _id and modifier props + * @param {String} [docId] DEPRECATED. The _id, if details is the modifier. + * @return {Object|Boolean} result - The object returned. * @return {String} {string("update"|"delete")} result.type - The type of updating happened. */ - "shippo/updateApiKey"(modifier, _id) { - // Important server-side check for security and data integrity - check(modifier, ShippoPackageConfig); - check(_id, String); + "shippo/updateApiKey"(details, docId) { + check(details, Object); + + // Backward compatibility + check(docId, Match.Optional(String)); + const id = docId || details._id; + const modifier = docId ? details : details.modifier; + + // Important server-side checks for security and data integrity + check(id, String); + ShippoPackageConfig.validate(modifier, { modifier: true }); // Make sure user has proper rights to this package - const { shopId } = Packages.findOne( - { _id }, - { field: { shopId: 1 } } - ); + const { shopId } = Packages.findOne({ _id: id }, { field: { shopId: 1 } }); if (shopId && Roles.userIsInRole(this.userId, shippingRoles, shopId)) { // If user wants to delete existing key if ({}.hasOwnProperty.call(modifier, "$unset")) { const customModifier = { $set: { "settings.apiKey": null } }; - Packages.update(_id, customModifier); + Packages.update(id, customModifier); // remove shop's existing Shippo Providers from Shipping Collection removeShippoProviders(false, shopId); @@ -228,7 +231,7 @@ export const methods = { // if not possible throws a relative Meteor Error (eg invalid_credentials) ShippoApi.methods.getAddressList.call({ apiKey }); // if everything is ok proceed with the api key update - Packages.update(_id, modifier); + Packages.update(id, modifier); // remove shop's existing Shippo Providers from Shipping Collection removeShippoProviders(false, shopId); @@ -330,7 +333,7 @@ export const methods = { Meteor.call("orders/shipmentDelivered", order); } - // A batch update might be better option. Unfortunately Reaction.import doesn't support + // A batch update might be better option. Unfortunately Reaction.importer doesn't support // .. Orders currently const orderUpdating = Orders.update({ _id: order._id @@ -447,6 +450,7 @@ export const methods = { errorDetails.message = "The 'shipping' property of this cart is either missing or incomplete."; return [[errorDetails], []]; } + const carrierAccounts = Object.keys(shippoDocs); let shippoShipment; try { @@ -473,7 +477,8 @@ export const methods = { return [[errorData], [currentMethodInfo]]; } - if (!shippoShipment.rates_list || shippoShipment.rates_list.length === 0) { + const shippoRates = shippoShipment.rates_list; + if (!shippoRates || shippoRates.length === 0) { const noShippingMethods = { requestStatus: "error", shippingProvider: "shippo", @@ -488,9 +493,7 @@ export const methods = { return [[noShippingMethods], [currentMethodInfo]]; } - const shippoRates = shippoShipment.rates_list; const reactionRates = ratesParser(shippoRates, shippoDocs); - return [reactionRates, []]; } diff --git a/imports/plugins/included/shipping-shippo/server/methods/shippoapi.js b/imports/plugins/included/shipping-shippo/server/methods/shippoapi.js index b042443ef29..1fd87eab2ab 100644 --- a/imports/plugins/included/shipping-shippo/server/methods/shippoapi.js +++ b/imports/plugins/included/shipping-shippo/server/methods/shippoapi.js @@ -1,8 +1,8 @@ /* eslint camelcase: 0 */ import Shippo from "shippo"; +import SimpleSchema from "simpl-schema"; import { Meteor } from "meteor/meteor"; import { ValidatedMethod } from "meteor/mdg:validated-method"; -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { Logger } from "/server/api"; import { purchaseAddressSchema, parcelSchema } from "../lib/shippoApiSchema"; @@ -108,12 +108,13 @@ ShippoApi.methods.getCarrierAccountsList = new ValidatedMethod({ ShippoApi.methods.createShipment = new ValidatedMethod({ name: "ShippoApi.methods.createShipment", validate: new SimpleSchema({ - shippoAddressFrom: { type: purchaseAddressSchema }, - shippoAddressTo: { type: purchaseAddressSchema }, - shippoParcel: { type: parcelSchema }, - purpose: { type: String, allowedValues: ["QUOTE", "PURCHASE"] }, - apiKey: { type: String }, - carrierAccounts: { type: [String], optional: true } + "shippoAddressFrom": purchaseAddressSchema, + "shippoAddressTo": purchaseAddressSchema, + "shippoParcel": parcelSchema, + "purpose": { type: String, allowedValues: ["QUOTE", "PURCHASE"] }, + "apiKey": String, + "carrierAccounts": { type: Array, optional: true }, + "carrierAccounts.$": String }).validator(), run({ shippoAddressFrom, shippoAddressTo, shippoParcel, purpose, apiKey, carrierAccounts }) { const shippoObj = new Shippo(apiKey); diff --git a/imports/plugins/included/sms/server/methods/sms.js b/imports/plugins/included/sms/server/methods/sms.js index a73f0263ccd..dc48dcb6419 100644 --- a/imports/plugins/included/sms/server/methods/sms.js +++ b/imports/plugins/included/sms/server/methods/sms.js @@ -1,7 +1,8 @@ import { Meteor } from "meteor/meteor"; import { check } from "meteor/check"; -import { Sms, Accounts } from "/lib/collections"; +import { Sms } from "/lib/collections"; import { Reaction, Logger } from "/server/api"; +import { formatPhoneNumber } from "/lib/api"; // We lazy load these in order to shave a few seconds off the time // it takes Meteor to start/restart the app. @@ -62,15 +63,25 @@ Meteor.methods({ check(userId, String); check(shopId, String); - const user = Accounts.findOne({ _id: userId }); - const addressBook = user && user.profile ? user.profile.addressBook : false; + const user = Meteor.users.findOne(userId); + if (!user) return; + + const addressBook = user.profile && user.profile.addressBook; + // check for addressBook phone - const phone = (Array.isArray(addressBook) && addressBook[0] && addressBook[0].phone) || false; + const phone = addressBook && addressBook.phone; + const country = addressBook && addressBook.country; - if (!phone) return; + if (!phone || !country) { + return; + } const smsSettings = Sms.findOne({ shopId }); - if (!smsSettings) return; + if (!smsSettings) { + return; + } + + const formattedPhone = formatPhoneNumber(phone, country); const { apiKey, apiToken, smsPhone, smsProvider } = smsSettings; if (smsProvider === "twilio") { @@ -78,7 +89,7 @@ Meteor.methods({ Promise.await(lazyLoadTwilio()); const client = new Twilio(apiKey, apiToken); client.messages.create({ - to: phone, + to: formattedPhone, from: smsPhone, body: message }, (err) => { @@ -93,7 +104,7 @@ Meteor.methods({ Logger.debug("choose nexmo"); Promise.await(lazyLoadNexmo()); const client = new Nexmo({ apiKey, apiSecret: apiToken }); - client.message.sendSms(smsPhone, phone, message, (err, result) => { + client.message.sendSms(smsPhone, formattedPhone, message, (err, result) => { if (err) { Logger.error("Nexmo error", err); } diff --git a/imports/plugins/included/social/client/components/settings.js b/imports/plugins/included/social/client/components/settings.js index ca66d1d49ac..82b646d3066 100644 --- a/imports/plugins/included/social/client/components/settings.js +++ b/imports/plugins/included/social/client/components/settings.js @@ -43,7 +43,7 @@ class SocialSettings extends Component { } getSchemaForField(provider, field) { - return SocialPackageConfig._schema[`settings.public.apps.${provider}.${field}`]; + return SocialPackageConfig.getDefinition(`settings.public.apps.${provider}.${field}`); } handleSettingChange = (event, value, name) => { diff --git a/imports/plugins/included/taxes-avalara/client/containers/avalaraSettingsFormContainer.js b/imports/plugins/included/taxes-avalara/client/containers/avalaraSettingsFormContainer.js index 8e78f55b92d..cddd7fd7064 100644 --- a/imports/plugins/included/taxes-avalara/client/containers/avalaraSettingsFormContainer.js +++ b/imports/plugins/included/taxes-avalara/client/containers/avalaraSettingsFormContainer.js @@ -50,7 +50,40 @@ const formSettings = { inputType: "number" }, "settings.avalara.password": { + renderComponent: "string", inputType: "password" + }, + "settings.avalara.apiLoginId": { + renderComponent: "string", + inputType: "text" + }, + "settings.avalara.username": { + renderComponent: "string", + inputType: "text" + }, + "settings.avalara.companyCode": { + renderComponent: "string", + inputType: "text" + }, + "settings.avalara.shippingTaxCode": { + renderComponent: "string", + inputType: "text" + }, + "settings.addressValidation.enabled": { + renderComponent: "boolean", + inputType: "checkbox" + }, + "settings.avalara.performTaxCalculation": { + renderComponent: "boolean", + inputType: "checkbox" + }, + "settings.avalara.enableLogging": { + renderComponent: "boolean", + inputType: "checkbox" + }, + "settings.avalara.commitDocuments": { + renderComponent: "boolean", + inputType: "checkbox" } }, logFieldsProp: { diff --git a/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js index 77fb8e2a336..fc68f83bd13 100644 --- a/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js +++ b/imports/plugins/included/taxes-avalara/lib/collections/schemas/schema.js @@ -1,4 +1,4 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; +import SimpleSchema from "simpl-schema"; import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -6,83 +6,82 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * TaxPackageConfig Schema */ -export const AvalaraPackageConfig = new SimpleSchema([ - TaxPackageConfig, { - "settings.avalara": { - type: Object, - optional: true - }, - "settings.avalara.enabled": { - type: Boolean, - optional: true, - defaultValue: false - }, - "settings.avalara.apiLoginId": { - label: "Avalara API Login ID", - type: String - }, - "settings.avalara.username": { - label: "Username", - type: String - }, - "settings.avalara.password": { - label: "Password", - type: String - }, - "settings.avalara.companyCode": { - label: "Company code", - type: String - }, - "settings.avalara.shippingTaxCode": { - label: "Shipping Tax Code", - type: String - }, - "settings.addressValidation.enabled": { - label: "Address Validation", - type: Boolean, - defaultValue: true - }, - "settings.addressValidation.countryList": { - label: "Enable Address Validation by Country", - type: [String], - optional: true, - defaultValue: ["US", "CA"] - }, - "settings.avalara.requestTimeout": { - label: "Request Timeout", - type: Number, - defaultValue: 1500 - }, - "settings.avalara.mode": { - label: "Production Mode", - type: Boolean, - defaultValue: false - }, - "settings.avalara.performTaxCalculation": { - label: "Perform Tax Calculation", - type: Boolean, - defaultValue: true - }, - "settings.avalara.enableLogging": { - label: "Enable Transaction Logging", - type: Boolean, - defaultValue: false - }, - "settings.avalara.companyId": { - type: String, - optional: true - }, - "settings.avalara.commitDocuments": { - label: "Commit Documents", - type: Boolean, - defaultValue: true - }, - "settings.avalara.logRetentionDuration": { - label: "Retain Logs Duration (Days)", - type: Number, - defaultValue: 30 - } +export const AvalaraPackageConfig = TaxPackageConfig.clone().extend({ + "settings.avalara": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.avalara.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.avalara.apiLoginId": { + type: String, + label: "Avalara API Login ID" + }, + "settings.avalara.username": { + type: String + }, + "settings.avalara.companyCode": { + type: String + }, + "settings.avalara.companyId": { + type: String, + optional: true + }, + "settings.avalara.password": { + type: String + }, + "settings.avalara.mode": { + label: "Production Mode", + type: Boolean, + defaultValue: false + }, + "settings.avalara.shippingTaxCode": { + label: "Shipping Tax Code", + type: String + }, + "settings.addressValidation.enabled": { + label: "Address Validation", + type: Boolean, + defaultValue: true + }, + "settings.avalara.commitDocuments": { + label: "Commit Documents", + type: Boolean, + defaultValue: true + }, + "settings.avalara.performTaxCalculation": { + label: "Perform Tax Calculation", + type: Boolean, + defaultValue: true + }, + "settings.avalara.enableLogging": { + label: "Enable Transaction Logging", + type: Boolean, + defaultValue: false + }, + "settings.avalara.logRetentionDuration": { + label: "Retain Logs Duration (Days)", + type: SimpleSchema.Integer, + defaultValue: 30 + }, + "settings.avalara.requestTimeout": { + label: "Request Timeout", + type: SimpleSchema.Integer, + defaultValue: 1500 + }, + "settings.addressValidation.countryList": { + label: "Enable Address Validation by Country", + type: Array, + optional: true, + defaultValue: ["US", "CA"] + }, + "settings.addressValidation.countryList.$": { + type: String } -]); +}); registerSchema("AvalaraPackageConfig", AvalaraPackageConfig); diff --git a/imports/plugins/included/taxes-avalara/server/hooks/hooks.js b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js index a8a7a857014..c0e9c1529b8 100644 --- a/imports/plugins/included/taxes-avalara/server/hooks/hooks.js +++ b/imports/plugins/included/taxes-avalara/server/hooks/hooks.js @@ -32,8 +32,9 @@ MethodHooks.after("taxes/calculate", (options) => { const taxAmount = taxes.reduce((totalTaxes, tax) => totalTaxes + tax.tax, 0); const taxRate = taxAmount / taxCalc.calcTaxable(cartToCalc); Meteor.call("taxes/setRate", cartId, taxRate, taxes); - } else if (result.error.errorCode === 503) { - Logger.error("timeout error: do nothing here"); + // for bad auth, timeout, or misconfiguration there's nothing we can do so keep moving + } else if ([503, 400, 401].includes(result.error.errorCode)) { + Logger.error("Timeout, Authentification, or Misconfiguration error: Not trying to estimate cart"); } else { Logger.error("Unknown error", result.error.errorCode); } diff --git a/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js b/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js index c3f914f4c6c..19e4c8c3b2e 100644 --- a/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js +++ b/imports/plugins/included/taxes-avalara/server/methods/taxCalc.js @@ -59,7 +59,7 @@ function checkConfiguration(packageData = taxCalc.getPackageData()) { } } if (!isValid) { - throw new Meteor.Error("bad-configuration", "The Avalara package is not configured correctly. Cannot continue"); + Logger.error("The Avalara package is not configured correctly. Cannot continue"); } return isValid; } @@ -96,11 +96,32 @@ function parseError(error) { let errorData; // The Avalara API constantly times out, so handle this special case first if (error.code === "ETIMEDOUT") { - errorData = { errorCode: 503, errorDetails: { message: "ETIMEDOUT", description: "The request timeod out" } }; + errorData = { + errorCode: 503, + type: "apiFailure", + errorDetails: { + message: "ETIMEDOUT", + description: "The request timed out" + } + }; return errorData; } - const errorDetails = []; - if (error.response.data.error.details) { + + if (error.response && error.response.statusCode === 401) { + // authentification error + errorData = { + errorCode: 401, + type: "apiFailure", + errorDetails: { + message: error.message, + description: error.description + } + }; + return errorData; + } + + if (error.response && error.response.data && error.response.data.error.details) { + const errorDetails = []; const { details } = error.response.data.error; for (const detail of details) { if (detail.severity === "Error") { @@ -110,7 +131,6 @@ function parseError(error) { errorData = { errorCode: details[0].number, errorDetails }; } else { Avalogger.error("Unknown error or error format"); - throw new Meteor.Error("bad-error", "Unknown error or error format"); } return errorData; } @@ -180,8 +200,20 @@ function avaGet(requestUrl, options = {}, testCredentials = true) { function avaPost(requestUrl, options) { const logObject = {}; const pkgData = taxCalc.getPackageData(); + // If package is not configured don't bother making an API call + if (!checkConfiguration(pkgData)) { + return { + error: { + errorCode: 400, + type: "apiFailure", + errorDetails: { + message: "API is not configured" + } + } + }; + } const appVersion = Reaction.getAppVersion(); - const meteorVersion = _.split(Meteor.release, "@")[1]; + const meteorVersion = Meteor.release.split("@")[1]; const machineName = os.hostname(); const avaClient = `Reaction; ${appVersion}; Meteor HTTP; ${meteorVersion}; ${machineName}`; const headers = { @@ -292,6 +324,18 @@ taxCalc.validateAddress = function (address) { const baseUrl = getUrl(); const requestUrl = `${baseUrl}addresses/resolve`; const result = avaPost(requestUrl, { data: addressToValidate }); + if (result.error) { + if (result.error.type === "apiError") { + // If we have a problem with the API there's no reason to tell the customer + // so let's consider this unvalidated but move along + Logger.error("API error, ignoring address validation"); + } + + if (result.error.type === "validationError") { + // We received a validation error so we need somehow pass this up to the client + Logger.error("Address Validation Error"); + } + } const content = result.data; if (content && content.messages) { ({ messages } = content); @@ -463,7 +507,7 @@ function cartToSalesOrder(cart) { * @returns {Object} result Result of SalesOrder call */ taxCalc.estimateCart = function (cart, callback) { - check(cart, Reaction.Schemas.Cart); + Reaction.Schemas.Cart.validate(cart); check(callback, Function); if (cart.items && cart.shipping && cart.shipping[0].address) { diff --git a/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js index c9a9082a7d3..1e8ddb3953b 100644 --- a/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js +++ b/imports/plugins/included/taxes-taxcloud/lib/collections/schemas/schema.js @@ -1,4 +1,3 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -6,38 +5,37 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * TaxPackageConfig Schema */ -export const TaxCloudPackageConfig = new SimpleSchema([ - TaxPackageConfig, { - "settings.taxcloud": { - type: Object, - optional: true - }, - "settings.taxcloud.enabled": { - type: Boolean, - optional: true, - defaultValue: false - }, - "settings.taxcloud.apiLoginId": { - type: String, - label: "TaxCloud API Login ID" - }, - "settings.taxcloud.apiKey": { - type: String, - label: "TaxCloud API Key" - }, - "settings.taxcloud.refreshPeriod": { - type: String, - label: "TaxCode Refresh Period", - defaultValue: "every 7 days", - optional: true - }, - "settings.taxcloud.taxCodeUrl": { - type: String, - label: "TaxCode API Url", - defaultValue: "https://taxcloud.net/tic/?format=json", - optional: true - } +export const TaxCloudPackageConfig = TaxPackageConfig.clone().extend({ + "settings.taxcloud": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.taxcloud.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.taxcloud.apiLoginId": { + type: String, + label: "TaxCloud API Login ID" + }, + "settings.taxcloud.apiKey": { + type: String, + label: "TaxCloud API Key" + }, + "settings.taxcloud.refreshPeriod": { + type: String, + label: "TaxCode Refresh Period", + defaultValue: "every 7 days", + optional: true + }, + "settings.taxcloud.taxCodeUrl": { + type: String, + label: "TaxCode API Url", + defaultValue: "https://taxcloud.net/tic/?format=json", + optional: true } -]); +}); registerSchema("TaxCloudPackageConfig", TaxCloudPackageConfig); diff --git a/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js index ca2f2933720..78f15fa0275 100644 --- a/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js +++ b/imports/plugins/included/taxes-taxcloud/server/jobs/taxcodes.js @@ -67,7 +67,7 @@ export default function () { } else { // we should always return "completed" job here, because errors are fine const success = "Latest TaxCloud TaxCodes were fetched successfully."; - Reaction.Import.flush(); + Reaction.Importer.flush(); Logger.debug(success); job.done(success, { repeatId: true }); diff --git a/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js index dd38d87b242..34ff23cd689 100644 --- a/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js +++ b/imports/plugins/included/taxes-taxjar/lib/collections/schemas/schema.js @@ -1,4 +1,3 @@ -import { SimpleSchema } from "meteor/aldeed:simple-schema"; import { TaxPackageConfig } from "/imports/plugins/core/taxes/lib/collections/schemas"; import { registerSchema } from "@reactioncommerce/reaction-collections"; @@ -6,23 +5,22 @@ import { registerSchema } from "@reactioncommerce/reaction-collections"; * TaxPackageConfig Schema */ -export const TaxJarPackageConfig = new SimpleSchema([ - TaxPackageConfig, { - "settings.taxjar": { - type: Object, - optional: true - }, - "settings.taxjar.enabled": { - type: Boolean, - optional: true, - defaultValue: false - }, - "settings.taxjar.apiLoginId": { - type: String, - label: "TaxJar API Login ID", - optional: true - } +export const TaxJarPackageConfig = TaxPackageConfig.clone().extend({ + "settings.taxjar": { + type: Object, + optional: true, + defaultValue: {} + }, + "settings.taxjar.enabled": { + type: Boolean, + optional: true, + defaultValue: false + }, + "settings.taxjar.apiLoginId": { + type: String, + label: "TaxJar API Login ID", + optional: true } -]); +}); registerSchema("TaxJarPackageConfig", TaxJarPackageConfig); diff --git a/imports/plugins/included/ui-search/client/index.js b/imports/plugins/included/ui-search/client/index.js index 76b8dcf86c4..d2e007544db 100644 --- a/imports/plugins/included/ui-search/client/index.js +++ b/imports/plugins/included/ui-search/client/index.js @@ -1,3 +1,2 @@ // Search Modal -import "./templates/searchModal/searchModal.html"; -import "./templates/searchModal/searchModal.js"; +export { default as SearchModalContainer } from "../lib/containers/searchModalContainer"; diff --git a/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.html b/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.html deleted file mode 100644 index 43cd369e164..00000000000 --- a/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.js b/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.js deleted file mode 100644 index 2184af194bd..00000000000 --- a/imports/plugins/included/ui-search/client/templates/searchModal/searchModal.js +++ /dev/null @@ -1,28 +0,0 @@ -import { $ } from "meteor/jquery"; -import { Template } from "meteor/templating"; -import SearchModalContainer from "../../../lib/containers/searchModalContainer"; - -/* - * searchModal helpers - */ -Template.searchModal.helpers({ - searchModal() { - return { - component: SearchModalContainer - }; - } -}); - -/* - * searchModal events - */ -Template.searchModal.events({ - "click [data-event-action=searchCollection]"(event) { - event.preventDefault(); - - $(".search-type-option").not(event.target).removeClass("search-type-active"); - $(event.target).addClass("search-type-active"); - - $("#search-input").focus(); - } -}); diff --git a/imports/plugins/included/ui-search/lib/components/searchModal.js b/imports/plugins/included/ui-search/lib/components/searchModal.js index f76045337a2..0e439dfb6d1 100644 --- a/imports/plugins/included/ui-search/lib/components/searchModal.js +++ b/imports/plugins/included/ui-search/lib/components/searchModal.js @@ -1,5 +1,6 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; +import classnames from "classnames"; import { Reaction } from "/client/api"; import { TextField, Button, IconButton, SortableTableLegacy } from "@reactioncommerce/reaction-ui"; import ProductGridContainer from "/imports/plugins/included/product-variant/containers/productGridContainer"; @@ -20,6 +21,17 @@ class SearchModal extends Component { value: PropTypes.string } + state = { + activeTab: "products" + } + + componentDidMount() { + // Focus and select all text in the search input + const { input } = this.textField.refs; + input.select(); + } + + isKeyboardAction(event) { // keyCode 32 (spacebar) // keyCode 13 (enter/return) @@ -28,10 +40,12 @@ class SearchModal extends Component { handleToggleProducts = () => { this.props.handleToggle("products"); + this.setState({ activeTab: "products" }); } handleToggleAccounts = () => { this.props.handleToggle("accounts"); + this.setState({ activeTab: "accounts" }); } handleOnKeyUpToggleProducts = (event) => { @@ -57,6 +71,7 @@ class SearchModal extends Component { { this.textField = input; }} label={`Search ${this.props.siteName}`} i18nKeyLabel="search.searchInputLabel" className="search-input" @@ -78,10 +93,20 @@ class SearchModal extends Component { renderSearchTypeToggle() { if (Reaction.hasPermission("admin")) { + const productTabClassName = classnames({ + "search-type-option": true, + "search-type-active": this.state.activeTab === "products" + }); + + const accountsTabClassName = classnames({ + "search-type-option": true, + "search-type-active": this.state.activeTab === "accounts" + }); + return (