diff --git a/.babelrc b/.babelrc index 9ccae96e..0d172c5c 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { + "plugins": ["lodash"], "presets": ["stage-2"] } diff --git a/.eslintrc b/.eslintrc index 39a5cdb9..b1896755 100644 --- a/.eslintrc +++ b/.eslintrc @@ -86,9 +86,9 @@ "jsx-quotes": [2, "prefer-double"], // http://eslint.org/docs/rules/jsx-quotes "react/no-deprecated": 1, "react/display-name": 1, - "react/forbid-prop-types": 1, + "react/forbid-prop-types": 0, "react/jsx-boolean-value": 0, - "react/jsx-closing-bracket-location": [1, "after-props"], + "react/jsx-closing-bracket-location": 1, "react/jsx-curly-spacing": 1, "react/jsx-indent-props": [1, 2], "react/jsx-max-props-per-line": [1, { diff --git a/.gitignore b/.gitignore index 15517295..a0e3fc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules npm-debug.log pids results +allure-results client/plugins.js server/plugins.js diff --git a/.meteor/packages b/.meteor/packages index e937c7a0..9df6d6e8 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -10,26 +10,26 @@ meteor-base@1.0.4 # Packages every Meteor app needs to have mobile-experience@1.0.4 # Packages for a great mobile UX blaze-html-templates@1.0.4 # Compile .html files into Meteor Blaze views -es5-shim@4.6.14 # ECMAScript 5 compatibility for older browsers. -ecmascript@0.5.8 # Enable ECMAScript2015+ syntax in app code +es5-shim@4.6.14_1 # ECMAScript 5 compatibility for older browsers. +ecmascript@0.5.8_1 # Enable ECMAScript2015+ syntax in app code audit-argument-checks@1.0.7 # ensure meteor method argument validation browser-policy@1.0.9 # security-related policies enforced by newer browsers juliancwirko:postcss # CSS post-processing plugin (replaces standard-minifier-css) -standard-minifier-js@1.2.0 # a minifier plugin used for Meteor apps by default +standard-minifier-js@1.2.0_1 # a minifier plugin used for Meteor apps by default session@1.1.6 # ReactiveDict whose contents are preserved across Hot Code Push tracker@1.1.0 # Meteor transparent reactive programming library -mongo@1.1.12 +mongo@1.1.12_1 random@1.0.10 reactive-var@1.0.10 reactive-dict@1.1.8 check@1.2.3 -http@1.2.9 +http@1.2.9_1 ddp-rate-limiter@1.0.5 underscore@1.0.9 -logging@1.1.15 +logging@1.1.15_1 reload@1.1.10 ejson@1.0.12 -less@2.7.5 +less@2.7.5_1 service-configuration@1.0.10 amplify mdg:validated-method @@ -75,6 +75,8 @@ risul:moment-timezone tmeasday:publish-counts vsivsi:job-collection react-meteor-data +percolate:migrations +johanbrook:publication-collector@1.0.2 # Testing packages dburles:factory diff --git a/.meteor/release b/.meteor/release index 72980bc2..2631a233 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.4.1.1 +METEOR@1.4.1.2 diff --git a/.meteor/versions b/.meteor/versions index f7a73a05..0b3afb09 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,4 +1,4 @@ -accounts-base@1.2.11 +accounts-base@1.2.12_1 accounts-facebook@1.0.10 accounts-google@1.0.10 accounts-oauth@1.1.13 @@ -16,8 +16,8 @@ allow-deny@1.0.5 amplify@1.0.0 audit-argument-checks@1.0.7 autoupdate@1.3.11 -babel-compiler@6.9.1 -babel-runtime@0.1.11 +babel-compiler@6.9.1_1 +babel-runtime@0.1.11_1 base64@1.0.9 binary-heap@1.0.9 blaze@2.1.9 @@ -28,7 +28,7 @@ browser-policy@1.0.9 browser-policy-common@1.0.10 browser-policy-content@1.0.11 browser-policy-framing@1.0.11 -caching-compiler@1.1.7 +caching-compiler@1.1.7_1 caching-html-compiler@1.0.7 callback-hook@1.0.9 cfs:access-point@0.1.49 @@ -53,21 +53,21 @@ cfs:upload-http@0.0.20 cfs:worker@0.1.4 check@1.2.3 chuangbo:cookie@1.1.0 -coffeescript@1.2.4_1 +coffeescript@1.2.4_2 dburles:factory@1.1.0 ddp@1.2.5 -ddp-client@1.3.1 +ddp-client@1.3.1_1 ddp-common@1.2.6 ddp-rate-limiter@1.0.5 -ddp-server@1.3.10 +ddp-server@1.3.10_1 deps@1.0.12 diff-sequence@1.0.6 dispatch:mocha@0.0.9 -ecmascript@0.5.8 -ecmascript-runtime@0.3.14 +ecmascript@0.5.8_1 +ecmascript-runtime@0.3.14_1 ejson@1.0.12 -email@1.1.17 -es5-shim@4.6.14 +email@1.1.17_1 +es5-shim@4.6.14_1 facebook@1.2.9 fastclick@1.0.12 geojson-utils@1.0.9 @@ -75,9 +75,10 @@ google@1.1.14 hot-code-push@1.0.4 html-tools@1.0.11 htmljs@1.0.11 -http@1.2.9 +http@1.2.9_1 id-map@1.0.8 jeremy:stripe@1.6.0 +johanbrook:publication-collector@1.0.2 jparker:crypto-core@0.1.0 jparker:crypto-md5@0.1.1 jparker:gravatar@0.5.1 @@ -89,14 +90,14 @@ kadira:blaze-layout@2.3.0 kadira:dochead@1.5.0 kadira:flow-router-ssr@3.13.0 launch-screen@1.0.12 -less@2.7.5 +less@2.7.5_1 livedata@1.0.18 localstorage@1.0.11 -logging@1.1.15 +logging@1.1.15_1 matb33:collection-hooks@0.8.4 mdg:validated-method@1.1.0 mdg:validation-error@0.5.1 -meteor@1.2.17 +meteor@1.2.17_1 meteor-base@1.0.4 meteorhacks:fast-render@2.16.0 meteorhacks:inject-data@2.0.0 @@ -104,27 +105,28 @@ meteorhacks:meteorx@1.4.1 meteorhacks:picker@1.0.3 meteorhacks:ssr@2.2.0 meteorhacks:subs-manager@1.6.4 -minifier-css@1.2.14 -minifier-js@1.2.14 +minifier-css@1.2.14_1 +minifier-js@1.2.14_1 minimongo@1.0.17 mobile-experience@1.0.4 mobile-status-bar@1.0.12 -modules@0.7.6 -modules-runtime@0.7.6 +modules@0.7.6_1 +modules-runtime@0.7.6_1 momentjs:moment@2.15.1 -mongo@1.1.12 +mongo@1.1.12_5 mongo-id@1.0.5 mongo-livedata@1.0.12 mrt:later@1.6.1 -npm-bcrypt@0.9.1 -npm-mongo@1.5.49 +npm-bcrypt@0.9.1_1 +npm-mongo@2.2.10_1 oauth@1.1.11 oauth-encryption@1.2.0 oauth1@1.1.10 oauth2@1.1.10 -observe-sequence@1.0.12 +observe-sequence@1.0.13 ongoworks:security@2.0.1 ordered-dict@1.0.8 +percolate:migrations@0.9.8 practicalmeteor:chai@2.1.0_1 practicalmeteor:mocha-core@1.0.1 practicalmeteor:sinon@1.14.1_2 @@ -147,18 +149,18 @@ shell-server@0.2.1 spacebars@1.0.13 spacebars-compiler@1.0.13 srp@1.0.9 -standard-minifier-js@1.2.0 +standard-minifier-js@1.2.0_1 templating@1.2.15 templating-compiler@1.2.15 templating-runtime@1.2.15 templating-tools@1.0.5 tmeasday:check-npm-versions@0.3.1 -tmeasday:publish-counts@0.7.3 +tmeasday:publish-counts@0.8.0 tracker@1.1.0 twitter@1.1.12 ui@1.0.12 underscore@1.0.9 url@1.0.10 vsivsi:job-collection@1.4.0 -webapp@1.3.11 +webapp@1.3.11_1 webapp-hashing@1.0.9 diff --git a/.pullapprove.yml b/.pullapprove.yml index d46b6bc0..67f479f4 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -42,14 +42,3 @@ groups: - mikemurray - zenweasel - kieckhafer - security: - author_approval: - ignored: true - reject_value: -100 - required: -1 - reset_on_push: - enabled: true - reset_on_reopened: - enabled: true - users: - - Capt-Slow diff --git a/.reaction/docker/base.dockerfile b/.reaction/docker/base.dockerfile new file mode 100644 index 00000000..5dc6bca6 --- /dev/null +++ b/.reaction/docker/base.dockerfile @@ -0,0 +1,49 @@ +FROM debian:jessie +MAINTAINER Reaction Commerce + +ENV NODE_VERSION "4.6.0" + +# Install MongoDB +ENV INSTALL_MONGO "true" +ENV MONGO_VERSION "3.2.10" +ENV MONGO_MAJOR "3.2" + +# Install PhantomJS +ENV INSTALL_PHANTOMJS "true" +ENV PHANTOM_VERSION "2.1.1" + +# build directories +ENV APP_SOURCE_DIR "/opt/reaction/src" +ENV APP_BUNDLE_DIR "/opt/reaction/dist" +ENV BUILD_SCRIPTS_DIR "/opt/reaction/build_scripts" + +# Add entrypoint and build scripts +COPY .reaction/docker/scripts $BUILD_SCRIPTS_DIR +RUN chmod -R +x $BUILD_SCRIPTS_DIR + +# install base dependencies and clean up +RUN cd $BUILD_SCRIPTS_DIR && \ + bash $BUILD_SCRIPTS_DIR/install-deps.sh && \ + bash $BUILD_SCRIPTS_DIR/install-node.sh && \ + bash $BUILD_SCRIPTS_DIR/install-mongo.sh && \ + bash $BUILD_SCRIPTS_DIR/install-phantom.sh && \ + bash $BUILD_SCRIPTS_DIR/post-install-cleanup.sh + +# copy the app to the container +ONBUILD COPY . $APP_SOURCE_DIR + +# install Meteor, build app, clean up +ONBUILD RUN cd $APP_SOURCE_DIR && \ + bash $BUILD_SCRIPTS_DIR/install-meteor.sh && \ + bash $BUILD_SCRIPTS_DIR/build-meteor.sh && \ + bash $BUILD_SCRIPTS_DIR/post-build-cleanup.sh + +# set the default port that Node will listen on +ENV PORT 80 +EXPOSE 80 + +WORKDIR $APP_BUNDLE_DIR/bundle + +# start the app +ENTRYPOINT ./entrypoint.sh +CMD [] diff --git a/.reaction/docker/build.sh b/.reaction/docker/build.sh new file mode 100755 index 00000000..ea1e5a80 --- /dev/null +++ b/.reaction/docker/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# build the base container and then the app container +docker build -f .reaction/docker/base.dockerfile -t reactioncommerce/base:latest . +docker build -t reactioncommerce/reaction:latest . diff --git a/.reaction/docker/reaction.ci.dockerfile b/.reaction/docker/reaction.ci.dockerfile deleted file mode 100644 index 70fad2b4..00000000 --- a/.reaction/docker/reaction.ci.dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -FROM debian:jessie -MAINTAINER Reaction Commerce - -ENV DEV_BUILD "true" - -ENV NODE_VERSION "4.4.7" - -# Install PhantomJS -ENV INSTALL_PHANTOMJS "true" - -# Meteor environment variables -ENV PORT "80" -ENV ROOT_URL "http://localhost" - -# build script directories -ENV APP_SOURCE_DIR "/var/src" -ENV APP_BUNDLE_DIR "/var/www" -ENV BUILD_SCRIPTS_DIR "/opt/reaction" - -# Install entrypoint and build scripts -COPY .reaction/docker/scripts $BUILD_SCRIPTS_DIR - -RUN chmod -R +x $BUILD_SCRIPTS_DIR - -# install base dependencies, cleanup -RUN bash $BUILD_SCRIPTS_DIR/install-deps.sh && \ - bash $BUILD_SCRIPTS_DIR/install-node.sh && \ - bash $BUILD_SCRIPTS_DIR/install-phantom.sh && \ - bash $BUILD_SCRIPTS_DIR/post-install-cleanup.sh - -# copy the app to the container, build it, cleanup -COPY . $APP_SOURCE_DIR - -RUN cd $APP_SOURCE_DIR && \ - bash $BUILD_SCRIPTS_DIR/install-meteor.sh && \ - bash $BUILD_SCRIPTS_DIR/build-meteor.sh && \ - bash $BUILD_SCRIPTS_DIR/post-build-cleanup.sh - -# switch to production meteor bundle -WORKDIR $APP_BUNDLE_DIR/bundle - -# 80 is the default meteor production port, while 3000 is development mode -EXPOSE 80 - -# start mongo and reaction -ENTRYPOINT ["./entrypoint.sh"] -CMD [] diff --git a/.reaction/docker/reaction.dev.dockerfile b/.reaction/docker/reaction.dev.dockerfile deleted file mode 100644 index 51592f90..00000000 --- a/.reaction/docker/reaction.dev.dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM debian:jessie -MAINTAINER Reaction Commerce - -ENV DEV_BUILD "true" - -ENV NODE_VERSION "4.4.7" - -# Install PhantomJS -ENV INSTALL_PHANTOMJS "true" - -# Meteor environment variables -ENV PORT "80" -ENV ROOT_URL "http://localhost" - -# build script directories -ENV APP_SOURCE_DIR "/var/src" -ENV APP_BUNDLE_DIR "/var/www" -ENV BUILD_SCRIPTS_DIR "/opt/reaction" - -# Install entrypoint and build scripts -COPY .reaction/docker/scripts $BUILD_SCRIPTS_DIR - -RUN chmod -R +x $BUILD_SCRIPTS_DIR - -# install base dependencies -RUN bash $BUILD_SCRIPTS_DIR/install-deps.sh && \ - bash $BUILD_SCRIPTS_DIR/install-node.sh && \ - bash $BUILD_SCRIPTS_DIR/install-phantom.sh && \ - bash $BUILD_SCRIPTS_DIR/install-meteor.sh - -# copy the app to the container, build it, cleanup -COPY . $APP_SOURCE_DIR - -RUN cd $APP_SOURCE_DIR && \ - bash $BUILD_SCRIPTS_DIR/build-meteor.sh && \ - bash $BUILD_SCRIPTS_DIR/post-build-cleanup.sh - -# switch to production meteor bundle -WORKDIR $APP_BUNDLE_DIR/bundle - -# 80 is the default meteor production port, while 3000 is development mode -EXPOSE 80 - -# start mongo and reaction -ENTRYPOINT ["./entrypoint.sh"] -CMD [] diff --git a/.reaction/docker/reaction.prod.dockerfile b/.reaction/docker/reaction.prod.dockerfile deleted file mode 100644 index a63399a0..00000000 --- a/.reaction/docker/reaction.prod.dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -FROM debian:jessie -MAINTAINER Reaction Commerce - -ENV NODE_VERSION "4.4.7" - -# Install PhantomJS -ENV INSTALL_PHANTOMJS "true" - -# Meteor environment variables -ENV PORT "80" -ENV ROOT_URL "http://localhost" - -# build script directories -ENV APP_SOURCE_DIR "/var/src" -ENV APP_BUNDLE_DIR "/var/www" -ENV BUILD_SCRIPTS_DIR "/opt/reaction" - -# Install entrypoint and build scripts -COPY .reaction/docker/scripts $BUILD_SCRIPTS_DIR - -RUN chmod -R +x $BUILD_SCRIPTS_DIR - -# copy the app to the container -COPY . $APP_SOURCE_DIR - -# install base dependencies, build app, cleanup -RUN bash $BUILD_SCRIPTS_DIR/install-deps.sh && \ - bash $BUILD_SCRIPTS_DIR/install-node.sh && \ - bash $BUILD_SCRIPTS_DIR/install-phantom.sh && \ - bash $BUILD_SCRIPTS_DIR/install-meteor.sh && \ - cd $APP_SOURCE_DIR && \ - bash $BUILD_SCRIPTS_DIR/build-meteor.sh && \ - bash $BUILD_SCRIPTS_DIR/post-build-cleanup.sh - -# switch to production meteor bundle -WORKDIR $APP_BUNDLE_DIR/bundle - -# 80 is the default meteor production port, while 3000 is development mode -EXPOSE 80 - -# start mongo and reaction -ENTRYPOINT ["./entrypoint.sh"] -CMD [] diff --git a/.reaction/docker/scripts/build-meteor.sh b/.reaction/docker/scripts/build-meteor.sh index 8e5f66c0..f72e3f4a 100755 --- a/.reaction/docker/scripts/build-meteor.sh +++ b/.reaction/docker/scripts/build-meteor.sh @@ -19,7 +19,7 @@ bash $BUILD_SCRIPTS_DIR/build-packages.sh bash $BUILD_SCRIPTS_DIR/plugin-loader.sh # Install app deps -meteor npm install --production +meteor npm install # build the source mkdir -p $APP_BUNDLE_DIR diff --git a/.reaction/docker/scripts/build-packages.sh b/.reaction/docker/scripts/build-packages.sh index f0575e1c..92ca7baf 100755 --- a/.reaction/docker/scripts/build-packages.sh +++ b/.reaction/docker/scripts/build-packages.sh @@ -1,11 +1,11 @@ #!/bin/bash # -# add bin/docker/packages to use custom build packages +# Add .reaction/docker/packages to use custom +# Meteor packages in the Docker build # -if [ -f bin/docker/packages ]; then +if [ -f .reaction/docker/packages ]; then echo "[-] Using custom Meteor packages file..." cp docker/packages .meteor/packages - exit 0 fi diff --git a/.reaction/docker/scripts/ci-build.sh b/.reaction/docker/scripts/ci-build.sh index 689ac173..0b56f500 100755 --- a/.reaction/docker/scripts/ci-build.sh +++ b/.reaction/docker/scripts/ci-build.sh @@ -11,8 +11,8 @@ if [[ -e ~/docker/image.tar ]]; then docker load -i ~/docker/image.tar fi -# build new image -docker build -t reactioncommerce/reaction:latest . +# build new base and app images +.reaction/docker/build.sh # if successful, save in cache mkdir -p ~/docker diff --git a/.reaction/docker/scripts/entrypoint.sh b/.reaction/docker/scripts/entrypoint.sh index 12499c6d..da5ab9bc 100755 --- a/.reaction/docker/scripts/entrypoint.sh +++ b/.reaction/docker/scripts/entrypoint.sh @@ -7,9 +7,17 @@ # set -e -# set default meteor values if they arent set -: ${PORT:="80"} -: ${ROOT_URL:="http://localhost"} +# start local mongodb if no external MONGO_URL was set +if [[ "${MONGO_URL}" == *"127.0.0.1"* ]]; then + if hash mongod 2>/dev/null; then + mkdir -p /data/db + printf "\n[-] External MONGO_URL not found. Starting local MongoDB...\n\n" + mongod --storageEngine=wiredTiger --fork --logpath /var/log/mongodb.log + else + echo "ERROR: Mongo not installed inside the container. Rebuild with INSTALL_MONGO=true" + exit 1 + fi +fi # Run meteor exec node ./main.js diff --git a/.reaction/docker/scripts/install-deps.sh b/.reaction/docker/scripts/install-deps.sh index 437785c7..99444f82 100755 --- a/.reaction/docker/scripts/install-deps.sh +++ b/.reaction/docker/scripts/install-deps.sh @@ -7,4 +7,4 @@ printf "\n[-] Installing base OS dependencies...\n\n" apt-get update -qq -y -apt-get install -qq -y --no-install-recommends curl ca-certificates bzip2 git build-essential python graphicsmagick +apt-get install -qq -y --no-install-recommends curl ca-certificates bzip2 build-essential python graphicsmagick diff --git a/.reaction/docker/scripts/install-mongo.sh b/.reaction/docker/scripts/install-mongo.sh new file mode 100644 index 00000000..bff619d6 --- /dev/null +++ b/.reaction/docker/scripts/install-mongo.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +if [ "${INSTALL_MONGO}" = "true" ]; then + + printf "\n[-] Installing MongoDB ${MONGO_VERSION}...\n\n" + + apt-key adv --keyserver ha.pool.sks-keyservers.net --recv-keys DFFA3DCF326E302C4787673A01C4E7FAAAB2461C + apt-key adv --keyserver ha.pool.sks-keyservers.net --recv-keys 42F3E95A2C4F08279C4960ADD68FA50FEA312927 + + echo "deb http://repo.mongodb.org/apt/debian jessie/mongodb-org/$MONGO_MAJOR main" > /etc/apt/sources.list.d/mongodb-org.list + + apt-get update + + apt-get install -y \ + mongodb-org=$MONGO_VERSION \ + mongodb-org-server=$MONGO_VERSION \ + mongodb-org-shell=$MONGO_VERSION \ + mongodb-org-mongos=$MONGO_VERSION \ + mongodb-org-tools=$MONGO_VERSION + + rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/mongodb + mv /etc/mongod.conf /etc/mongod.conf.orig + +fi diff --git a/.reaction/docker/scripts/install-node.sh b/.reaction/docker/scripts/install-node.sh index 417d837c..3d02ca4f 100755 --- a/.reaction/docker/scripts/install-node.sh +++ b/.reaction/docker/scripts/install-node.sh @@ -2,7 +2,6 @@ set -e -: ${NODE_VERSION:=4.4.7} : ${NODE_ARCH:=x64} printf "\n[-] Installing Node ${NODE_VERSION}...\n\n" diff --git a/.reaction/docker/scripts/install-phantom.sh b/.reaction/docker/scripts/install-phantom.sh index 86add804..be4b1148 100755 --- a/.reaction/docker/scripts/install-phantom.sh +++ b/.reaction/docker/scripts/install-phantom.sh @@ -6,19 +6,20 @@ if [ "${INSTALL_PHANTOMJS}" = "true" ]; then printf "\n[-] Installing Phantom.js...\n\n" - PHANTOM_VERSION="2.1.1" PHANTOM_JS="phantomjs-$PHANTOM_VERSION-linux-x86_64" apt-get update - apt-get install build-essential wget chrpath libssl-dev libxft-dev -y + apt-get install -y wget chrpath libssl-dev libxft-dev - cd ~ + cd /tmp wget https://github.com/Medium/phantomjs/releases/download/v$PHANTOM_VERSION/$PHANTOM_JS.tar.bz2 tar xvjf $PHANTOM_JS.tar.bz2 mv $PHANTOM_JS /usr/local/share ln -sf /usr/local/share/$PHANTOM_JS/bin/phantomjs /usr/local/share/phantomjs ln -sf /usr/local/share/$PHANTOM_JS/bin/phantomjs /usr/local/bin/phantomjs ln -sf /usr/local/share/$PHANTOM_JS/bin/phantomjs /usr/bin/phantomjs - + + apt-get -y purge wget + phantomjs -v fi diff --git a/.reaction/docker/scripts/post-build-cleanup.sh b/.reaction/docker/scripts/post-build-cleanup.sh index 63a09506..99fc0e50 100755 --- a/.reaction/docker/scripts/post-build-cleanup.sh +++ b/.reaction/docker/scripts/post-build-cleanup.sh @@ -29,10 +29,10 @@ rm -rf /opt/nodejs/bin/npm rm -rf /opt/nodejs/lib/node_modules/npm/ # remove meteor -rm -rf /usr/bin/meteor +rm -rf /usr/local/bin/meteor rm -rf /root/.meteor # remove os dependencies -apt-get -qq -y purge ca-certificates curl git bzip2 +apt-get -qq -y purge ca-certificates curl bzip2 apt-get -qq -y autoremove rm -rf /var/lib/apt/lists/* diff --git a/.reaction/docker/scripts/post-install-cleanup.sh b/.reaction/docker/scripts/post-install-cleanup.sh index cc80d243..dc3969eb 100755 --- a/.reaction/docker/scripts/post-install-cleanup.sh +++ b/.reaction/docker/scripts/post-install-cleanup.sh @@ -14,5 +14,5 @@ rm -rf /root/.cache /root/.config /root/.local rm -rf /tmp/* # remove os dependencies -apt-get -qq -y autoremove +apt-get -y autoremove rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile b/Dockerfile deleted file mode 120000 index 3513d4dd..00000000 --- a/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -.reaction/docker/reaction.prod.dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ff16b37f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM reactioncommerce/base:latest + +# Default environment variables +ENV ROOT_URL "http://localhost" +ENV MONGO_URL "mongodb://127.0.0.1:27017/reaction" diff --git a/circle.yml b/circle.yml index 44a2bc9f..d9782365 100644 --- a/circle.yml +++ b/circle.yml @@ -1,10 +1,10 @@ machine: node: - version: 4.5.0 + version: 4.6.0 services: - docker pre: - - meteor update || curl https://install.meteor.com | /bin/sh + - hash meteor 2>/dev/null || curl https://install.meteor.com | /bin/sh dependencies: cache_directories: @@ -26,15 +26,20 @@ deployment: prequel: branch: development commands: + - docker tag reactioncommerce/base:latest reactioncommerce/base:devel - docker tag reactioncommerce/reaction:latest reactioncommerce/prequel:latest - docker tag reactioncommerce/reaction:latest reactioncommerce/prequel:$CIRCLE_BUILD_NUM - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS + - docker push reactioncommerce/base:devel - docker push reactioncommerce/prequel:$CIRCLE_BUILD_NUM - docker push reactioncommerce/prequel:latest release: branch: master commands: + - docker tag reactioncommerce/base:latest reactioncommerce/base:$CIRCLE_BUILD_NUM - docker tag reactioncommerce/reaction:latest reactioncommerce/reaction:$CIRCLE_BUILD_NUM - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS + - docker push reactioncommerce/base:$CIRCLE_BUILD_NUM + - docker push reactioncommerce/base:latest - docker push reactioncommerce/reaction:$CIRCLE_BUILD_NUM - docker push reactioncommerce/reaction:latest diff --git a/client/modules/i18n/currency.js b/client/modules/i18n/currency.js new file mode 100644 index 00000000..ce4a4ca7 --- /dev/null +++ b/client/modules/i18n/currency.js @@ -0,0 +1,124 @@ +import accounting from "accounting-js"; +import { Meteor } from "meteor/meteor"; +import { Template } from "meteor/templating"; +import { localeDep, i18nextDep } from "./main"; +import { Reaction, Logger, i18next } from "/client/api"; + +/** + * formatPriceString + * @summary return shop /locale specific formatted price + * also accepts a range formatted with " - " + * @param {String} currentPrice - currentPrice or "xx.xx - xx.xx" formatted String + * @return {String} returns locale formatted and exchange rate converted values + */ +export function formatPriceString(formatPrice) { + const locale = Reaction.Locale.get(); + + if (typeof locale !== "object" || typeof locale.currency !== "object") { + // locale not yet loaded, so we don"t need to return anything. + return false; + } + + if (typeof formatPrice !== "string" && typeof formatPrice !== "number") { + return false; + } + + // for the cases then we have only one price. It is a number. + const currentPrice = formatPrice.toString(); + let price = 0; + const prices = ~currentPrice.indexOf(" - ") ? + currentPrice.split(" - ") : [currentPrice]; + + // basic "for" is faster then "for ...of" for arrays. We need more speed here + const len = prices.length; + for (let i = 0; i < len; i++) { + const originalPrice = prices[i]; + try { + // we know the locale, but we don"t know exchange rate. In that case we + // should return to default shop currency + if (typeof locale.currency.rate !== "number") { + throw new Meteor.Error("exchangeRateUndefined"); + } + prices[i] *= locale.currency.rate; + + price = _formatPrice(price, originalPrice, prices[i], + currentPrice, locale.currency, i, len); + } catch (error) { + Logger.debug("currency error, fallback to shop currency"); + price = _formatPrice(price, originalPrice, prices[i], + currentPrice, locale.shopCurrency, i, len); + } + } + + return price; +} + +export function formatNumber(currentPrice) { + const locale = Reaction.Locale.get(); + let price = currentPrice; + const format = Object.assign({}, locale.currency, { + format: "%v" + }); + const shopFormat = Object.assign({}, locale.shopCurrency, { + format: "%v" + }); + + if (typeof locale.currency === "object" && locale.currency.rate) { + price = currentPrice * locale.currency.rate; + return accounting.formatMoney(price, format); + } + + Logger.debug("currency error, fallback to shop currency"); + return accounting.formatMoney(currentPrice, shopFormat); +} + +/** + * _formatPrice + * private function for formatting locale currency + * @private + * @param {Number} price price + * @param {Number} originalPrice originalPrice + * @param {Number} actualPrice actualPrice + * @param {Number} currentPrice currentPrice + * @param {Number} currency currency + * @param {Number} pos position + * @param {Number} len length + * @return {Number} formatted price + */ +function _formatPrice(price, originalPrice, actualPrice, currentPrice, currency, + pos, len) { + // this checking for locale.shopCurrency mostly + if (typeof currency !== "object") { + return false; + } + + let adjustedPrice = actualPrice; + let formattedPrice; + + // Precision is mis-used in accounting js. Scale is the propery term for number + // of decimal places. Let's adjust it here so accounting.js does not break. + if (currency.scale !== undefined) { + currency.precision = currency.scale; + } + + // If there are no decimal places, in the case of the Japanese Yen, we adjust it here. + if (currency.scale === 0) { + adjustedPrice = actualPrice * 100; + } + + // @param {string} currency.where: If it presents - in situation then two + // prices in string, currency sign will be placed just outside the right price. + // For now it should be manually added to fixtures shop data. + if (typeof currency.where === "string" && currency.where === "right" && + len > 1 && pos === 0) { + const modifiedCurrency = Object.assign({}, currency, { + symbol: "" + }); + formattedPrice = accounting.formatMoney(adjustedPrice, modifiedCurrency); + } else { + // accounting api: http://openexchangerates.github.io/accounting.js/ + formattedPrice = accounting.formatMoney(adjustedPrice, currency); + } + + return price === 0 ? currentPrice.replace(originalPrice, formattedPrice) : price.replace(originalPrice, formattedPrice); +} diff --git a/client/modules/i18n/helpers.js b/client/modules/i18n/helpers.js index ba045644..4c9453b2 100644 --- a/client/modules/i18n/helpers.js +++ b/client/modules/i18n/helpers.js @@ -1,7 +1,6 @@ -import accounting from "accounting-js"; -import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; import { localeDep, i18nextDep } from "./main"; +import { formatPriceString } from "./currency"; import { Reaction, Logger, i18next } from "/client/api"; /** @@ -49,120 +48,9 @@ Template.registerHelper("currencySymbol", function () { */ Template.registerHelper("formatPrice", function (formatPrice) { localeDep.depend(); - - const locale = Reaction.Locale.get(); - - if (typeof locale !== "object" || typeof locale.currency !== "object") { - // locale not yet loaded, so we don"t need to return anything. - return false; - } - - if (typeof formatPrice !== "string" && typeof formatPrice !== "number") { - return false; - } - - // for the cases then we have only one price. It is a number. - const currentPrice = formatPrice.toString(); - let price = 0; - const prices = ~currentPrice.indexOf(" - ") ? - currentPrice.split(" - ") : [currentPrice]; - - // basic "for" is faster then "for ...of" for arrays. We need more speed here - const len = prices.length; - for (let i = 0; i < len; i++) { - const originalPrice = prices[i]; - try { - // we know the locale, but we don"t know exchange rate. In that case we - // should return to default shop currency - if (typeof locale.currency.rate !== "number") { - throw new Meteor.Error("exchangeRateUndefined"); - } - prices[i] *= locale.currency.rate; - - price = _formatPrice(price, originalPrice, prices[i], - currentPrice, locale.currency, i, len); - } catch (error) { - Logger.debug("currency error, fallback to shop currency"); - price = _formatPrice(price, originalPrice, prices[i], - currentPrice, locale.shopCurrency, i, len); - } - } - - return price; + return formatPriceString(formatPrice); }); -Reaction.Currency = {}; - -Reaction.Currency.formatNumber = function (currentPrice) { - const locale = Reaction.Locale.get(); - let price = currentPrice; - const format = Object.assign({}, locale.currency, { - format: "%v" - }); - const shopFormat = Object.assign({}, locale.shopCurrency, { - format: "%v" - }); - - if (typeof locale.currency === "object" && locale.currency.rate) { - price = currentPrice * locale.currency.rate; - return accounting.formatMoney(price, format); - } - - Logger.debug("currency error, fallback to shop currency"); - return accounting.formatMoney(currentPrice, shopFormat); -}; - -/** - * _formatPrice - * private function for formatting locale currency - * @private - * @param {Number} price price - * @param {Number} originalPrice originalPrice - * @param {Number} actualPrice actualPrice - * @param {Number} currentPrice currentPrice - * @param {Number} currency currency - * @param {Number} pos position - * @param {Number} len length - * @return {Number} formatted price - */ -function _formatPrice(price, originalPrice, actualPrice, currentPrice, currency, - pos, len) { - // this checking for locale.shopCurrency mostly - if (typeof currency !== "object") { - return false; - } - - let adjustedPrice = actualPrice; - let formattedPrice; - - // Precision is mis-used in accounting js. Scale is the propery term for number - // of decimal places. Let's adjust it here so accounting.js does not break. - if (currency.scale !== undefined) { - currency.precision = currency.scale; - } - - // If there are no decimal places, in the case of the Japanese Yen, we adjust it here. - if (currency.scale === 0) { - adjustedPrice = actualPrice * 100; - } - - // @param {string} currency.where: If it presents - in situation then two - // prices in string, currency sign will be placed just outside the right price. - // For now it should be manually added to fixtures shop data. - if (typeof currency.where === "string" && currency.where === "right" && - len > 1 && pos === 0) { - const modifiedCurrency = Object.assign({}, currency, { - symbol: "" - }); - formattedPrice = accounting.formatMoney(adjustedPrice, modifiedCurrency); - } else { - // accounting api: http://openexchangerates.github.io/accounting.js/ - formattedPrice = accounting.formatMoney(adjustedPrice, currency); - } - - return price === 0 ? currentPrice.replace(originalPrice, formattedPrice) : price.replace(originalPrice, formattedPrice); -} - Object.assign(Reaction, { /** * translateRegistry diff --git a/client/modules/i18n/index.js b/client/modules/i18n/index.js index 1f59e2ae..8bae4969 100644 --- a/client/modules/i18n/index.js +++ b/client/modules/i18n/index.js @@ -1,4 +1,5 @@ import i18next, { getBrowserLanguage, i18nextDep, localeDep } from "./main"; +export * from "./currency"; export { i18next, diff --git a/imports/plugins/core/checkout/client/helpers/cart.js b/imports/plugins/core/checkout/client/helpers/cart.js index 2429a35d..8b9c8556 100644 --- a/imports/plugins/core/checkout/client/helpers/cart.js +++ b/imports/plugins/core/checkout/client/helpers/cart.js @@ -71,16 +71,10 @@ Template.registerHelper("cart", function () { * @summary gets current cart billing address / payment name * @return {String} returns cart.billing[0].fullName */ - Template.registerHelper("cartPayerName", function () { const cart = Cart.findOne(); - if (cart) { - if (cart.billing) { - if (cart.billing[0].address) { - if (cart.billing[0].address.fullName) { - return cart.billing[0].address.fullName; - } - } - } + if (cart && cart.billing && cart.billing[0] && cart.billing[0].address && cart.billing[0].address.fullName) { + const name = cart.billing[0].address.fullName; + if (name.replace(/[a-zA-Z ]*/, "").length === 0) return name; } }); diff --git a/imports/plugins/core/layout/client/templates/layout/alerts/inlineAlerts.js b/imports/plugins/core/layout/client/templates/layout/alerts/inlineAlerts.js index 763528f4..5b56658e 100644 --- a/imports/plugins/core/layout/client/templates/layout/alerts/inlineAlerts.js +++ b/imports/plugins/core/layout/client/templates/layout/alerts/inlineAlerts.js @@ -143,3 +143,5 @@ Alerts = { }, collection_: new Mongo.Collection(null) }; + +export default Alerts; diff --git a/imports/plugins/core/orders/client/templates/list/ordersList.html b/imports/plugins/core/orders/client/templates/list/ordersList.html index ed5ddafb..ceb8c4e2 100644 --- a/imports/plugins/core/orders/client/templates/list/ordersList.html +++ b/imports/plugins/core/orders/client/templates/list/ordersList.html @@ -97,7 +97,7 @@

diff --git a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js index 2e7d4bc8..c391ff77 100644 --- a/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js +++ b/imports/plugins/core/orders/client/templates/workflow/shippingInvoice.js @@ -4,7 +4,7 @@ import accounting from "accounting-js"; import { Meteor } from "meteor/meteor"; import { Template } from "meteor/templating"; import { ReactiveVar } from "meteor/reactive-var"; -import { Reaction, i18next, Logger } from "/client/api"; +import { i18next, Logger, formatNumber } from "/client/api"; import { NumericInput } from "/imports/plugins/core/ui/client/components"; import { Media, Orders, Shops } from "/lib/collections"; import _ from "lodash"; @@ -248,7 +248,7 @@ Template.coreOrderShippingInvoice.helpers({ }, money(amount) { - return Reaction.Currency.formatNumber(amount); + return formatNumber(amount); }, disabled() { diff --git a/imports/plugins/core/revisions/client/components/publishControls.js b/imports/plugins/core/revisions/client/components/publishControls.js new file mode 100644 index 00000000..c5b46e47 --- /dev/null +++ b/imports/plugins/core/revisions/client/components/publishControls.js @@ -0,0 +1,277 @@ +import React, { Component, PropTypes } from "react"; +import { + Button, + ButtonToolbar, + Divider, + DropDownMenu, + Menu, + MenuItem, + Popover, + Translation +} from "/imports/plugins/core/ui/client/components"; +import SimpleDiff from "./simpleDiff"; +import { Translatable } from "/imports/plugins/core/ui/client/providers"; + +class PublishControls extends Component { + constructor(props) { + super(props); + + this.state = { + showDiffs: false + }; + + this.handleToggleShowChanges = this.handleToggleShowChanges.bind(this); + this.handlePublishClick = this.handlePublishClick.bind(this); + } + + handleToggleShowChanges() { + this.setState({ + showDiffs: !this.state.showDiffs + }); + } + + handlePublishClick() { + if (this.props.onPublishClick) { + this.props.onPublishClick(this.props.revisions); + } + } + + handleVisibilityChange = (event, value) => { + if (this.props.onVisibilityChange) { + let isDocumentVisible = false; + + if (value === "public") { + isDocumentVisible = true; + } + + this.props.onVisibilityChange(event, isDocumentVisible); + } + } + + handleAction = (event, value) => { + if (this.props.onAction) { + this.props.onAction(event, value, this.props.documentIds); + } + } + + get showChangesButtonLabel() { + if (!this.showDiffs) { + return "Show Changes"; + } + + return "Hide Changes"; + } + + get showChangesButtoni18nKeyLabel() { + if (!this.showDiffs) { + return "app.showChanges"; + } + + return "app.hideChanges"; + } + + get revisionIds() { + if (this.hasRevisions) { + return this.props.revisions.map(revision => revision._id); + } + return false; + } + + get hasRevisions() { + return Array.isArray(this.props.revisions) && this.props.revisions.length; + } + + get diffs() { + return this.props.revisions; + } + + get showDiffs() { + return this.diffs && this.state.showDiffs; + } + + get isVisible() { + if (Array.isArray(this.props.revisions) && this.props.revisions.length) { + const primaryRevision = this.props.revisions[0]; + + if (primaryRevision.documentData.isVisible) { + return "public"; + } + } else if (Array.isArray(this.props.documents) && this.props.documents.length) { + const primaryDocument = this.props.documents[0]; + + if (primaryDocument.isVisible) { + return "public"; + } + } + + return "private"; + } + + /** + * Getter hasChanges + * @return {Boolean} one or more revision has changes + */ + get hasChanges() { + // Verify we even have any revision at all + if (this.hasRevisions) { + // Loop through all revisions to determin if they have changes + const diffHasActualChanges = this.props.revisions.map((revision) => { + // We probably do have chnages to publish + // Note: Sometimes "updatedAt" will cause false positives, but just incase, lets + // enable the publish button anyway. + if (Array.isArray(revision.diff) && revision.diff.length) { + return true; + } + + // If all else fails, we will disable the button + return false; + }); + + // If even one revision has changes we should enable the publish button + return diffHasActualChanges.some((element) => { + return element === true; + }); + } + + // No revisions, no publishing + return false; + } + + renderChanges() { + if (this.showDiffs) { + const diffs = this.props.revisions.map((revision) => { + return ; + }); + + return ( +
+ {diffs} +
+ ); + } + return null; + } + + renderDeletionStatus() { + if (this.hasChanges) { + if (this.props.revisions[0].documentData.isDeleted) { + return ( + - ); + const extraProps = {}; + + if (tagName === "a") { + extraProps.href = "#"; + } + + const buttonProps = Object.assign({ + "className": classes, + "data-event-action": eventAction, + "onMouseOut": this.handleButtonMouseOut, + "onMouseOver": this.handleButtonMouseOver, + "onClick": this.handleClick, + "type": "button" + }, attrs, extraProps); + + + // Create a react fragment for all the button children + let buttonChildren; + + if (iconAfter) { + buttonChildren = createFragment({ + label: this.renderLabel(), + icon: this.renderIcon(), + children: this.props.children + }); + } else { + buttonChildren = createFragment({ + icon: this.renderIcon(), + label: this.renderLabel(), + children: this.props.children + }); + } + + // Button with tooltip gets some special treatment + if (tooltip) { + return React.createElement(tagName, buttonProps, + + + {buttonChildren} + + + ); + } + + // Normal button, without tooltip + return React.createElement(tagName, buttonProps, buttonChildren); } } Button.propTypes = { active: PropTypes.bool, children: PropTypes.node, - className: PropTypes.string, + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + disabled: PropTypes.bool, eventAction: PropTypes.string, i18nKeyLabel: PropTypes.string, i18nKeyTitle: PropTypes.string, + i18nKeyToggleOnLabel: PropTypes.string, i18nKeyTooltip: PropTypes.string, icon: PropTypes.string, + iconAfter: PropTypes.bool, label: PropTypes.string, + onClick: PropTypes.func, onIcon: PropTypes.string, + primary: PropTypes.bool, status: PropTypes.string, + tagName: PropTypes.string, title: PropTypes.string, toggle: PropTypes.bool, toggleOn: PropTypes.bool, - tooltip: PropTypes.string + toggleOnLabel: PropTypes.string, + tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.node]), + value: PropTypes.any }; Button.defaultProps = { - toggle: false, - active: false + active: false, + disabled: false, + iconAfter: false, + tagName: "button", + toggle: false }; export default Button; diff --git a/imports/plugins/core/ui/client/components/button/handle.js b/imports/plugins/core/ui/client/components/button/handle.js new file mode 100644 index 00000000..8a219cf1 --- /dev/null +++ b/imports/plugins/core/ui/client/components/button/handle.js @@ -0,0 +1,34 @@ +import React, { PropTypes } from "react"; +import { Icon } from "../icon"; + +/** + * Handle is a special type of button used for drag handles. + * It uses the fa-bars icon by default, and does not have click or hover states + * + * Use this button in places where you need a pre-styled button for drag handles + * + * @param {Object} props Props passed into component + * @returns {node} component with pre-configured icon for dragging + */ +const Handle = (props) => { + const handle = ( +
+ +
+ ); + + if (props.connectDragSource) { + return props.connectDragSource(handle); + } + + return handle; +}; + +Handle.propTypes = { + connectDragSource: PropTypes.func +}; + +export default Handle; diff --git a/imports/plugins/core/ui/client/components/button/iconButton.js b/imports/plugins/core/ui/client/components/button/iconButton.js index 776bfc70..da00e534 100644 --- a/imports/plugins/core/ui/client/components/button/iconButton.js +++ b/imports/plugins/core/ui/client/components/button/iconButton.js @@ -1,8 +1,4 @@ -/* eslint no-unused-vars: 1 */ -// -// TODO review PropTypes import in iconButton.js -// -import React, { Component, PropTypes } from "react"; +import React, { Component } from "react"; import classnames from "classnames"; import Button from "./button.jsx"; @@ -15,35 +11,36 @@ class IconButton extends Component { } = this.props; -// this.props.buttonKind === 'flat' -// default should be default, flat is new css that makes the bakcground tarnsparent - + // this.props.buttonKind === 'flat' + // default should be default, flat is new css that makes the bakcground tarnsparent let buttonClassName; if (this.props.kind === "flat") { buttonClassName = classnames({ "rui": true, "button": true, + "icon": true, + "icon-only": true, "flat": true }); - } - else if (this.props.kind === "close") { + } else if (this.props.kind === "close") { buttonClassName = classnames({ "rui": true, "button": true, + "icon-only": true, "close": true }); - } - else { + } else { buttonClassName = classnames({ "rui": true, "button": true, "edit": true, + "icon-only": true, "variant-edit": true }); } - let iconClassName = classnames({ + const iconClassName = classnames({ "fa-lg": true, [icon]: true }); diff --git a/imports/plugins/core/ui/client/components/button/index.js b/imports/plugins/core/ui/client/components/button/index.js new file mode 100644 index 00000000..03ca42d7 --- /dev/null +++ b/imports/plugins/core/ui/client/components/button/index.js @@ -0,0 +1,5 @@ +export { default as Button } from "./button.jsx"; +export { default as IconButton } from "./iconButton"; +export { default as EditButton } from "./editButton"; +export { default as VisibilityButton } from "./visibilityButton"; +export { default as Handle } from "./handle"; diff --git a/imports/plugins/core/ui/client/components/button/visibilityButton.js b/imports/plugins/core/ui/client/components/button/visibilityButton.js new file mode 100644 index 00000000..27895b02 --- /dev/null +++ b/imports/plugins/core/ui/client/components/button/visibilityButton.js @@ -0,0 +1,25 @@ +import React from "react"; +import IconButton from "./iconButton"; + +/** + * Visibility button is a special type of Icon Button that is toggable by default + * and presents a eye icon in its on state, and a eye-slash icon when it is off. + * + * Use this button in places where you need a pre-styled button for toggling visibility + * states of components. + * + * @param {Object} props Props passed into component + * @returns {IconButton} Retruns an IconButton component with pre-configured icons for visibility + */ +const VisibilityButton = (props) => { + return ( + + ); +}; + +export default VisibilityButton; diff --git a/imports/plugins/core/ui/client/components/buttonGroup/buttonGroup.js b/imports/plugins/core/ui/client/components/buttonGroup/buttonGroup.js index 3f26df34..b5bdb36c 100644 --- a/imports/plugins/core/ui/client/components/buttonGroup/buttonGroup.js +++ b/imports/plugins/core/ui/client/components/buttonGroup/buttonGroup.js @@ -1,32 +1,23 @@ -// import React from "react"; -// import classnames from "classnames"; -// -// const Items = ReactionUI.Components.Items; -// -// class ButtonGroup extends React.Component { -// -// renderButtons() { -// if (this.props.children) { -// const items = this.props.children.map((item, index) => { -// // if (this.props.autoWrap) { return ( {React.cloneElement(item)} ); } -// -// return React.cloneElement(item); -// }); -// -// return items; -// } -// } -// -// render() { -// const classes = classnames({rui: true, buttons: true}) -// -// return ( -//
-// -// {this.renderButtons()} -// -//
-// ); -// } -// } -// export default ButtonGroup; +import React, { Component, PropTypes} from "react"; +import classnames from "classnames"; + +class ButtonGroup extends Component { + render() { + const baseClassName = classnames({ + "rui": true, + "btn-group": true + }); + + return ( +
+ {this.props.children} +
+ ); + } +} + +ButtonGroup.propTypes = { + children: PropTypes.node +}; + +export default ButtonGroup; diff --git a/imports/plugins/core/ui/client/components/buttonGroup/buttonToolbar.js b/imports/plugins/core/ui/client/components/buttonGroup/buttonToolbar.js new file mode 100644 index 00000000..63de8f86 --- /dev/null +++ b/imports/plugins/core/ui/client/components/buttonGroup/buttonToolbar.js @@ -0,0 +1,23 @@ +import React, { Component, PropTypes} from "react"; +import classnames from "classnames"; + +class ButtonToolbar extends Component { + render() { + const baseClassName = classnames({ + "rui": true, + "btn-toolbar": true + }); + + return ( +
+ {this.props.children} +
+ ); + } +} + +ButtonToolbar.propTypes = { + children: PropTypes.node +}; + +export default ButtonToolbar; diff --git a/imports/plugins/core/ui/client/components/buttonGroup/index.js b/imports/plugins/core/ui/client/components/buttonGroup/index.js new file mode 100644 index 00000000..acd0a2e8 --- /dev/null +++ b/imports/plugins/core/ui/client/components/buttonGroup/index.js @@ -0,0 +1,2 @@ +export { default as ButtonGroup } from "./buttonGroup"; +export { default as ButtonToolbar } from "./buttonToolbar"; diff --git a/imports/plugins/core/ui/client/components/cards/card.js b/imports/plugins/core/ui/client/components/cards/card.js new file mode 100644 index 00000000..1143996c --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/card.js @@ -0,0 +1,17 @@ +import React, { Component, PropTypes } from "react"; + +class Card extends Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} + +Card.propTypes = { + children: PropTypes.node +}; + +export default Card; diff --git a/imports/plugins/core/ui/client/components/cards/cardBody.js b/imports/plugins/core/ui/client/components/cards/cardBody.js new file mode 100644 index 00000000..5b6ffe93 --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/cardBody.js @@ -0,0 +1,17 @@ +import React, { Component, PropTypes } from "react"; + +class CardBody extends Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} + +CardBody.propTypes = { + children: PropTypes.node +}; + +export default CardBody; diff --git a/imports/plugins/core/ui/client/components/cards/cardGroup.js b/imports/plugins/core/ui/client/components/cards/cardGroup.js new file mode 100644 index 00000000..fb11a741 --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/cardGroup.js @@ -0,0 +1,17 @@ +import React, { Component, PropTypes } from "react"; + +class CardGroup extends Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} + +CardGroup.propTypes = { + children: PropTypes.node +}; + +export default CardGroup; diff --git a/imports/plugins/core/ui/client/components/cards/cardHeader.js b/imports/plugins/core/ui/client/components/cards/cardHeader.js new file mode 100644 index 00000000..5ec11365 --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/cardHeader.js @@ -0,0 +1,34 @@ +import React, { Component, PropTypes } from "react"; +import CardTitle from "./cardTitle"; + +class CardHeader extends Component { + + renderTitle() { + if (this.props.title) { + return ( + + ); + } + return null; + } + + render() { + return ( +
+ {this.renderTitle()} + {this.props.children} +
+ ); + } +} + +CardHeader.propTypes = { + children: PropTypes.node, + i18nKeyTitle: PropTypes.string, + title: PropTypes.string +}; + +export default CardHeader; diff --git a/imports/plugins/core/ui/client/components/cards/cardTitle.js b/imports/plugins/core/ui/client/components/cards/cardTitle.js new file mode 100644 index 00000000..ebadb7df --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/cardTitle.js @@ -0,0 +1,29 @@ +import React, { Component, PropTypes } from "react"; +import { Translation } from "../translation"; + +class CardTitle extends Component { + + render() { + const { element, ...props } = this.props; + + if (element) { + return React.cloneElement(element, props); + } + + return ( +

+ + {this.props.children} +

+ ); + } +} + +CardTitle.propTypes = { + children: PropTypes.node, + element: PropTypes.node, + i18nKeyTitle: PropTypes.string, + title: PropTypes.string +}; + +export default CardTitle; diff --git a/imports/plugins/core/ui/client/components/cards/index.js b/imports/plugins/core/ui/client/components/cards/index.js new file mode 100644 index 00000000..2f7708ed --- /dev/null +++ b/imports/plugins/core/ui/client/components/cards/index.js @@ -0,0 +1,5 @@ +export { default as Card } from "./card"; +export { default as CardHeader } from "./cardHeader"; +export { default as CardTitle } from "./cardTitle"; +export { default as CardBody } from "./cardBody"; +export { default as CardGroup } from "./cardGroup"; diff --git a/imports/plugins/core/ui/client/components/checkbox/checkbox.js b/imports/plugins/core/ui/client/components/checkbox/checkbox.js new file mode 100644 index 00000000..72b8715a --- /dev/null +++ b/imports/plugins/core/ui/client/components/checkbox/checkbox.js @@ -0,0 +1,39 @@ +import React, { Component, PropTypes } from "react"; +import { Translation } from "/imports/plugins/core/ui/client/components"; + +class Checkbox extends Component { + handleChange = (event) => { + if (this.props.onChange) { + const isInputChecked = !this.props.checked; + this.props.onChange(event, isInputChecked, this.props.name); + } + } + + render() { + return ( + + ); + } +} + +Checkbox.defaultProps = { + checked: false +}; + +Checkbox.propTypes = { + checked: PropTypes.bool, + i18nKeyLabel: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string, + onChange: PropTypes.func +}; + +export default Checkbox; diff --git a/imports/plugins/core/ui/client/components/checkbox/index.js b/imports/plugins/core/ui/client/components/checkbox/index.js new file mode 100644 index 00000000..d7ffe3a0 --- /dev/null +++ b/imports/plugins/core/ui/client/components/checkbox/index.js @@ -0,0 +1 @@ +export { default as Checkbox } from "./checkbox"; diff --git a/imports/plugins/core/ui/client/components/divider/divider.js b/imports/plugins/core/ui/client/components/divider/divider.js new file mode 100644 index 00000000..44d2f26e --- /dev/null +++ b/imports/plugins/core/ui/client/components/divider/divider.js @@ -0,0 +1,44 @@ +import React, { Component, PropTypes } from "react"; +import classnames from "classnames"; +import { Translation } from "../"; + +class Divider extends Component { + renderLabel() { + return ( + + ); + } + + render() { + const { label, i18nKeyLabel } = this.props; + const classes = classnames({ + rui: true, + separator: true, + divider: true, + labeled: label || i18nKeyLabel + }); + + if (label) { + return ( +
+
+ + + +
+
+ ); + } + + return ( +
+ ); + } +} + +Divider.propTypes = { + i18nKeyLabel: PropTypes.string, + label: PropTypes.string +}; + +export default Divider; diff --git a/imports/plugins/core/ui/client/components/forms/field_group.js b/imports/plugins/core/ui/client/components/forms/fieldGroup.js similarity index 100% rename from imports/plugins/core/ui/client/components/forms/field_group.js rename to imports/plugins/core/ui/client/components/forms/fieldGroup.js diff --git a/imports/plugins/core/ui/client/components/icon/icon.jsx b/imports/plugins/core/ui/client/components/icon/icon.jsx index 4ab1190d..c8828282 100644 --- a/imports/plugins/core/ui/client/components/icon/icon.jsx +++ b/imports/plugins/core/ui/client/components/icon/icon.jsx @@ -1,7 +1,7 @@ -import React from "react"; -import classnames from "classnames"; +import React, { Component, PropTypes } from "react"; +import classnames from "classnames/dedupe"; -class Icon extends React.Component { +class Icon extends Component { render() { const { icon } = this.props; let classes; @@ -11,12 +11,17 @@ class Icon extends React.Component { classes = icon; } else { classes = classnames({ - fa: true, + "fa": true, [`fa-${icon}`]: true }); } } + classes = classnames({ + "rui": true, + "font-icon": true, + }, classes, this.props.className); + return ( ); @@ -24,7 +29,8 @@ class Icon extends React.Component { } Icon.propTypes = { - icon: React.PropTypes.string.isRequired + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + icon: PropTypes.string.isRequired }; export default Icon; diff --git a/imports/plugins/core/ui/client/components/icon/index.js b/imports/plugins/core/ui/client/components/icon/index.js new file mode 100644 index 00000000..313e78e5 --- /dev/null +++ b/imports/plugins/core/ui/client/components/icon/index.js @@ -0,0 +1 @@ +export { default as Icon } from "./icon"; diff --git a/imports/plugins/core/ui/client/components/index.js b/imports/plugins/core/ui/client/components/index.js index b7eec7c4..4317ec9d 100644 --- a/imports/plugins/core/ui/client/components/index.js +++ b/imports/plugins/core/ui/client/components/index.js @@ -1,11 +1,26 @@ // export ButtonGroup from "./buttonGroup/buttonGroup"; +export { Alerts, Alert } from "./alerts"; export { default as Icon } from "./icon/icon"; -export { default as Seperator } from "./separator/separator"; +export { default as CircularProgress } from "./progress/circularProgress"; +export { default as Divider } from "./divider/divider"; +export { default as Items } from "./items/items"; export { default as Item } from "./items/item"; +export { default as TextField } from "./textfield/textfield"; export { default as NumericInput } from "./numericInput/numericInput"; +export { Button, IconButton, EditButton, VisibilityButton, Handle } from "./button"; +export { Translation, Currency } from "./translation"; +export { default as Tooltip } from "./tooltip/tooltip"; +export { Metadata, Metafield } from "./metadata"; +export { TagList, TagItem } from "./tags"; +export { Card, CardHeader, CardBody, CardGroup, CardTitle } from "./cards"; +export { MediaGallery, MediaItem } from "./media"; export { default as FlatButton } from "./button/flatButton"; -export { default as IconButton } from "./button/iconButton"; -export { default as EditButton } from "./button/editButton"; +export { default as SortableTable } from "./table/table"; +export { Checkbox } from "./checkbox"; export { default as Loading } from "./loading/loading"; -export { default as FieldGroup } from "./forms/field_group"; +export { default as FieldGroup } from "./forms/fieldGroup"; +export * from "./toolbar"; +export { default as Popover } from "./popover/popover"; +export * from "./menu"; +export * from "./buttonGroup"; diff --git a/imports/plugins/core/ui/client/components/items/items.js b/imports/plugins/core/ui/client/components/items/items.js new file mode 100644 index 00000000..02fed25b --- /dev/null +++ b/imports/plugins/core/ui/client/components/items/items.js @@ -0,0 +1,19 @@ +import React from "react"; + +class Items extends React.Component { + render() { + return ( +
+ {this.props.children} +
+ ); + } +} + +Items.displayName = "Items"; + +Items.propTypes = { + children: React.PropTypes.node +}; + +export default Items; diff --git a/imports/plugins/core/ui/client/components/loading/loading.js b/imports/plugins/core/ui/client/components/loading/loading.js new file mode 100644 index 00000000..1a979b85 --- /dev/null +++ b/imports/plugins/core/ui/client/components/loading/loading.js @@ -0,0 +1,14 @@ +import React, { Component } from "react"; +import CircularProgress from "../progress/circularProgress"; + +class Loading extends Component { + render() { + return ( +
+ +
+ ); + } +} + +export default Loading; diff --git a/imports/plugins/core/ui/client/components/loading/loading.jsx b/imports/plugins/core/ui/client/components/loading/loading.jsx deleted file mode 100644 index af09401f..00000000 --- a/imports/plugins/core/ui/client/components/loading/loading.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { Component, PropTypes } from "react"; -import assign from "domkit/appendVendorPrefix"; -import insertKeyframesRule from "domkit/insertKeyframesRule"; - -// Loading Animations -// inspired by http://madscript.com/halogen - -class Loading extends Component { - - getBallStyle() { - return { - backgroundColor: this.props.color, - width: this.props.size, - height: this.props.size, - margin: this.props.margin, - borderRadius: "100%", - verticalAlign: this.props.verticalAlign - }; - } - - getAnimationStyle() { - const keyframes = { - "0%": { - transform: "scale(1)" - }, - "50%": { - transform: "scale(0.5)", - opacity: 0.7 - }, - "100%": { - transform: "scale(1)", - opacity: 1 - } - }; - - const random = top => Math.random() * top; - - const animationName = insertKeyframesRule(keyframes); - const animationDuration = ((random(100) / 100) + 0.6) + "s"; - const animationDelay = ((random(100) / 100) - 0.2) + "s"; - - const animation = [animationName, animationDuration, animationDelay, "infinite", "ease"].join(" "); - const animationFillMode = "both"; - - return { - animation: animation, - animationFillMode: animationFillMode - }; - } - - getStyle(i) { - return assign(this.getBallStyle(i), this.getAnimationStyle(), { - display: "inline-block" - }); - } - - renderLoader(loading) { - if (loading) { - const style = { - width: (parseFloat(this.props.size) * 3) + parseFloat(this.props.margin) * 6, - fontSize: 0 - }; - - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - return null; - } - - render() { - return this.renderLoader(this.props.loading); - } -} - -Loading.propTypes = { - className: PropTypes.string, - color: PropTypes.string, - id: PropTypes.string, - loading: PropTypes.bool, - margin: PropTypes.string, - size: PropTypes.string, - verticalAlign: PropTypes.string -}; - -Loading.defaultProps = { - className: "loader-wrapper", - color: "#666", - loading: true, - margin: "2px", - size: "15px" -}; - -export default Loading; diff --git a/imports/plugins/core/ui/client/components/media/index.js b/imports/plugins/core/ui/client/components/media/index.js new file mode 100644 index 00000000..bc438039 --- /dev/null +++ b/imports/plugins/core/ui/client/components/media/index.js @@ -0,0 +1,2 @@ +export { default as MediaGallery } from "./mediaGallery"; +export { default as MediaItem } from "./media"; diff --git a/imports/plugins/core/ui/client/components/media/media.js b/imports/plugins/core/ui/client/components/media/media.js new file mode 100644 index 00000000..a2a6b3b1 --- /dev/null +++ b/imports/plugins/core/ui/client/components/media/media.js @@ -0,0 +1,102 @@ +import React, { Component, PropTypes } from "react"; +import { IconButton } from "../"; +import { SortableItem } from "../../containers"; + + +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); + } + } + + renderControls() { + if (this.props.editable) { + return ( +
+ +
+ ); + } + + return null; + } + + get defaultSource() { + return this.props.defaultSource || "/resources/placeholder.gif"; + } + + get source() { + if (typeof this.props.source === "object" && this.props.source) { + return this.props.source.url() || this.defaultSource; + } + + return this.props.source || this.defaultSource; + } + + renderImage() { + const image = ( + + ); + + return image; + } + + render() { + 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, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onRemoveMedia: PropTypes.func, + source: PropTypes.oneOfType([PropTypes.string, PropTypes.object]) +}; + +export default SortableItem("media", MediaItem); diff --git a/imports/plugins/core/ui/client/components/media/media.jsx b/imports/plugins/core/ui/client/components/media/media.jsx deleted file mode 100644 index 603fe68f..00000000 --- a/imports/plugins/core/ui/client/components/media/media.jsx +++ /dev/null @@ -1,39 +0,0 @@ -// -// class Media extends React.Component { -// -// /** -// * handleDrop -// * @summary On drop of a file onto this component, upload it -// * @param {Event} event - Event object -// * @return {void} no return value -// */ -// handleDrop = (event) => { -// // Reaction.Media.productFileUpload(event); -// console.log("Drop!", event); -// } -// -// /** -// * renderImage -// * @summary Render an image tag for media type "image" -// * @return {JSX} image -// */ -// renderImage() { -// // TODO: Maybe not hard code this image, unless its part of this package -// const imageUrl = this.props.media || "/resources/placeholder.gif"; -// return ; -// } -// -// /** -// * render -// * @return {JSX} media component -// */ -// render() { -// return ( -//
-// {this.renderImage()} -//
-// ); -// } -// } -// -// ReactionUI.Components.Media = Media diff --git a/imports/plugins/core/ui/client/components/media/mediaGallery.js b/imports/plugins/core/ui/client/components/media/mediaGallery.js new file mode 100644 index 00000000..76be7307 --- /dev/null +++ b/imports/plugins/core/ui/client/components/media/mediaGallery.js @@ -0,0 +1,142 @@ +import React, { Component, PropTypes } from "react"; +import Dropzone from "react-dropzone"; +import MediaItem from "./media"; + +class MediaGallery extends Component { + get hasMedia() { + return Array.isArray(this.props.media) && this.props.media.length > 0; + } + + get allowFeaturedMediaHover() { + if (this.props.allowFeaturedMediaHover && this.props.featuredMedia) { + return true; + } + + return false; + } + + get featuredMedia() { + return this.props.featuredMedia; + } + + handleDropClick = () => { + this.refs.dropzone.open(); + } + + renderAddItem() { + if (this.props.editable) { + return ( +
+ +
+ +
+
+ ); + } + + return null; + } + + renderMedia() { + if (this.hasMedia) { + return this.props.media.map((media, index) => { + if (index === 0 && this.allowFeaturedMediaHover) { + return ( + + ); + } + + return ( + + ); + }); + } + + return ( + + ); + } + + renderMediaGalleryUploader() { + let gallery; + + // Only render media only if there is any + if (this.hasMedia) { + gallery = this.renderMedia(); + } + + return ( +
+ +
+ {gallery} + {this.renderAddItem()} +
+
+
+ ); + } + + renderMediaGallery() { + return ( +
+
+ {this.renderMedia()} +
+
+ ); + } + + render() { + if (this.props.editable) { + return this.renderMediaGalleryUploader(); + } + + return this.renderMediaGallery(); + } +} + +MediaGallery.propTypes = { + allowFeaturedMediaHover: PropTypes.bool, + editable: PropTypes.bool, + featuredMedia: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + media: PropTypes.arrayOf(PropTypes.object), + 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/menu/dropDownMenu.js b/imports/plugins/core/ui/client/components/menu/dropDownMenu.js new file mode 100644 index 00000000..d60b967e --- /dev/null +++ b/imports/plugins/core/ui/client/components/menu/dropDownMenu.js @@ -0,0 +1,76 @@ +import React, { Children, Component, PropTypes } from "react"; +import { + Button, + Menu, + Popover +} from "../"; + +class DropDownMenu extends Component { + constructor(props) { + super(props); + + this.state = { + label: undefined + }; + } + + handleMenuItemChange = (event, value, menuItem) => { + this.setState({ + label: menuItem.props.label || value + }); + + if (this.props.onChange) { + this.props.onChange(event, value); + } + } + + get label() { + let label = this.state.label; + Children.forEach(this.props.children, (element) => { + if (element.props.value === this.props.value) { + label = element.props.label; + } + }); + + if (!label) { + const children = Children.toArray(this.props.children); + if (children.length) { + return children[0].props.label; + } + } + + return label; + } + + render() { + return ( + + } + > + + {this.props.children} + + + ); + } +} + +DropDownMenu.propTypes = { + children: PropTypes.node, + isEnabled: PropTypes.bool, + onChange: PropTypes.func, + onPublishClick: PropTypes.func, + revisions: PropTypes.arrayOf(PropTypes.object), + translation: PropTypes.shape({ + lang: PropTypes.string + }), + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number]) +}; + +export default DropDownMenu; diff --git a/imports/plugins/core/ui/client/components/menu/index.js b/imports/plugins/core/ui/client/components/menu/index.js new file mode 100644 index 00000000..63a9f6f7 --- /dev/null +++ b/imports/plugins/core/ui/client/components/menu/index.js @@ -0,0 +1,3 @@ +export { default as Menu } from "./menu"; +export { default as MenuItem } from "./menuItem"; +export { default as DropDownMenu } from "./dropDownMenu"; diff --git a/imports/plugins/core/ui/client/components/menu/menu.js b/imports/plugins/core/ui/client/components/menu/menu.js new file mode 100644 index 00000000..87e6ad5a --- /dev/null +++ b/imports/plugins/core/ui/client/components/menu/menu.js @@ -0,0 +1,48 @@ +import React, { Children, Component, PropTypes } from "react"; +import TetherComponent from "react-tether"; +import classnames from "classnames"; + + +class Menu extends Component { + + handleChange = (event, value, menuItem) => { + if (this.props.onChange) { + this.props.onChange(event, value, menuItem); + } + } + + renderMenuItems() { + if (this.props.children) { + return Children.map(this.props.children, (element) => { + const newChild = React.cloneElement(element, { + onClick: this.handleChange, + active: element.props.value === this.props.value + }); + + return ( +
  • {newChild}
  • + ); + }); + } + } + + render() { + return ( +
      + {this.renderMenuItems()} +
    + ); + } +} + +Menu.propTypes = { + attachment: PropTypes.string, + children: PropTypes.node, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number]) +}; + +Menu.defaultProps = { + attachment: "top" +}; + +export default Menu; diff --git a/imports/plugins/core/ui/client/components/menu/menuItem.js b/imports/plugins/core/ui/client/components/menu/menuItem.js new file mode 100644 index 00000000..1216b4be --- /dev/null +++ b/imports/plugins/core/ui/client/components/menu/menuItem.js @@ -0,0 +1,80 @@ +import React, { Component, PropTypes } from "react"; +import classnames from "classnames/dedupe"; +import Icon from "../icon/icon.jsx"; +import { Translation } from "../"; + +class MenuItem extends Component { + + handleClick = (event) => { + event.preventDefault(); + if (this.props.onClick && this.props.disabled === false) { + this.props.onClick(event, this.props.value, this); + } + } + + renderIcon() { + if (this.props.icon) { + return ( + + ); + } + return null; + } + + renderLabel() { + if (this.props.label) { + return ( + + ); + } + + return null; + } + + render() { + const baseClassName = classnames({ + "rui": true, + "menu-item": true, + "active": this.props.active, + "disabled": this.props.disabled === true + }, this.props.className); + + return ( + + {this.renderIcon()} + {this.renderLabel()} + + ); + } +} + +MenuItem.propTypes = { + active: PropTypes.bool, + children: PropTypes.node, + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + disabled: PropTypes.bool, + eventAction: PropTypes.string, + i18nKeyLabel: PropTypes.string, + i18nKeySelectedLabel: PropTypes.string, + icon: PropTypes.string, + label: PropTypes.string, + onClick: PropTypes.func, + selectionLabel: PropTypes.string, + value: PropTypes.any +}; + +MenuItem.defaultProps = { + active: false, + disabled: false +}; + +export default MenuItem; diff --git a/imports/plugins/core/ui/client/components/metadata/index.js b/imports/plugins/core/ui/client/components/metadata/index.js new file mode 100644 index 00000000..7ee3c6c5 --- /dev/null +++ b/imports/plugins/core/ui/client/components/metadata/index.js @@ -0,0 +1,2 @@ +export { default as Metadata } from "./metadata"; +export { default as Metafield } from "./metafield"; diff --git a/imports/plugins/core/ui/client/components/metadata/metadata.js b/imports/plugins/core/ui/client/components/metadata/metadata.js new file mode 100644 index 00000000..ade3d334 --- /dev/null +++ b/imports/plugins/core/ui/client/components/metadata/metadata.js @@ -0,0 +1,124 @@ +import React, { Component, PropTypes } from "react"; +import Metafield from "./metafield"; + +class Metadata extends Component { + /** + * Handle form submit + * @param {Event} event Event object + * @return {void} no return value + */ + handleSubmit = (event) => { + event.preventDefault(); + } + + handleMetaChange = (event, metafield, index) => { + if (this.props.onMetaChange) { + this.props.onMetaChange(event, metafield, index); + } + } + + handleMetaSave = (event, metafield, index) => { + if (this.props.onMetaSave) { + this.props.onMetaSave(event, metafield, index); + } + } + + handleMetaRemove = (event, metafield, index) => { + if (this.props.onMetaRemove) { + this.props.onMetaRemove(event, metafield, index); + } + } + + /** + * Render user readable metadata + * @return {JSX} metadata + */ + renderMetadata() { + if (this.props.metafields) { + return this.props.metafields.map((metadata, index) => { + return ( +
    +
    {metadata.key}
    +
    {metadata.value}
    +
    + ); + }); + } + + return null; + } + + /** + * Render a metadata form + * @return {JSX} metadata forms for each row of metadata + */ + renderMetadataForm() { + if (this.props.metafields) { + return this.props.metafields.map((metadata, index) => { + return ( + + ); + }); + } + + return null; + } + + renderMetadataCreateForm() { + return ( + + ); + } + + /** + * render + * @return {JSX} component + */ + render() { + // Admin editable metadata + if (this.props.editable) { + return ( +
    + {this.renderMetadataForm()} + {this.renderMetadataCreateForm()} +
    + ); + } + + // User readable metadata + return ( +
    + {this.renderMetadata()} +
    + ); + } +} + +Metadata.defaultProps = { + editable: true +}; + +// Prop Types +Metadata.propTypes = { + editable: PropTypes.bool, + metafields: PropTypes.arrayOf(PropTypes.object), + newMetafield: PropTypes.object, + onMetaChange: PropTypes.func, + onMetaRemove: PropTypes.func, + onMetaSave: PropTypes.func +}; + +export default Metadata; diff --git a/imports/plugins/core/ui/client/components/metadata/metadata.jsx b/imports/plugins/core/ui/client/components/metadata/metadata.jsx deleted file mode 100644 index bb5cdd80..00000000 --- a/imports/plugins/core/ui/client/components/metadata/metadata.jsx +++ /dev/null @@ -1,124 +0,0 @@ -// import React from "react"; -// -// // import TextField from "reaction-ui/textfield" -// // TODO: For now lets pretend we have to do imports -// const TextField = ReactionUI.Components.TextField; -// const Button = ReactionUI.Components.Button; -// const Seperator = ReactionUI.Components.Seperator; -// const Item = ReactionUI.Components.Item; -// const Items = ReactionUI.Components.Items; -// -// class Metadata extends React.Component { -// -// /** -// * Handle form submit -// * @param {Event} event Event object -// * @return {void} no return value -// */ -// handleSubmit = (event) => { -// event.preventDefault(); -// } -// -// handleRemove = (event) => { -// console.log("Remove!!"); -// } -// -// handleSort = (event) => { -// console.log("sort!!!!"); -// } -// -// /** -// * Render user readable metadata -// * @return {JSX} metadata -// */ -// renderMetadata() { -// return this.props.metafields.map((metadata, index) => { -// return ( -//
    -//
    {metadata.key}
    -//
    {metadata.value}
    -//
    -// ); -// }); -// } -// -// /** -// * Render a metadata form -// * @return {JSX} metadata forms for each row of metadata -// */ -// renderMetadataForm() { -// const fields = this.props.metafields.map((metadata, index) => { -// return ( -// -//
    -// -// -// -//
    -//
    -// ); -// }); -// -// // Blank fields for creating new metadata -// // fields.push( -// // -// // ); -// -// return fields; -// } -// -// renderMetadataCreateForm() { -// -// return ( -// -//
    -// -// -// -//
    -// ); -// } -// -// /** -// * Render a tag creation form -// * @return {JSX} blank tag for creating new tags -// */ -// renderBlankEditableTag() { -// return ( -//
    -//
    -//
    -// ); -// } -// -// /** -// * Render component -// * @return {JSX} tag component -// */ -// render() { -// if (this.props.editable) { -// return this.renderEditableTag(); -// } else if (this.props.blank) { -// return this.renderBlankEditableTag(); -// } -// -// return this.renderTag(); -// } -// } -// -// Tag.propTypes = { -// blank: React.PropTypes.bool, -// editable: React.PropTypes.bool, -// -// // Event handelers -// onTagBookmark: React.PropTypes.func, -// onTagCreate: React.PropTypes.func, -// onTagMouseOut: React.PropTypes.func, -// onTagMouseOver: React.PropTypes.func, -// onTagRemove: React.PropTypes.func, -// onTagUpdate: React.PropTypes.func, -// -// parentTag: PropTypes.Tag, -// placeholder: React.PropTypes.string, -// showBookmark: React.PropTypes.bool, -// tag: PropTypes.Tag -// }; -// -// ReactionUI.Components.Tag = Tag; +import React, { Component, PropTypes } from "react"; +import classnames from "classnames"; +import Autosuggest from "react-autosuggest"; +import { Router } from "/client/api"; +import { i18next } from "/client/api"; +import { Button, Handle } from "/imports/plugins/core/ui/client/components"; +import { SortableItem } from "../../containers"; + + +class Tag extends Component { + displayName: "Tag"; + + get tag() { + return this.props.tag || { + name: "" + }; + } + + get inputPlaceholder() { + return i18next.t(this.props.i18nKeyInputPlaceholder || "tags.tagName", { + defaultValue: this.props.inputPlaceholder || "Tag Name" + }); + } + + getSuggestionValue(suggestion) { + return suggestion.label; + } + + saveTag(event) { + if (this.props.onTagSave) { + this.props.onTagSave(event, this.props.tag); + } + } + + /** + * Handle tag form submit events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagFormSubmit = (event) => { + event.preventDefault(); + this.saveTag(event); + }; + + /** + * Handle tag remove events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagRemove = () => { + if (this.props.onTagRemove) { + this.props.onTagRemove(this.props.tag); + } + }; + + /** + * Handle tag update events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagUpdate = (event) => { + if (this.props.onTagUpdate && event.keyCode === 13) { + this.props.onTagUpdate(this.props.tag._id, event.target.value); + } + }; + + handleTagKeyDown = (event) => { + if (event.keyCode === 13) { + this.saveTag(event); + } + } + + /** + * Handle tag mouse out events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagMouseOut = (event) => { + // event.preventDefault(); + if (this.props.onTagMouseOut) { + this.props.onTagMouseOut(event, this.props.tag); + } + }; + + /** + * Handle tag mouse over events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagMouseOver = (event) => { + if (this.props.onTagMouseOver) { + this.props.onTagMouseOver(event, this.props.tag); + } + }; + + /** + * Handle tag inout blur events and pass them up the component chain + * @param {Event} event Event object + * @return {void} no return value + */ + handleTagInputBlur = (event) => { + if (this.props.onTagInputBlur) { + this.props.onTagInputBlur(event, this.props.tag); + } + }; + + handleInputChange = (event, { newValue }) => { + if (this.props.onTagUpdate) { + const updatedTag = Object.assign({}, this.props.tag, { + name: newValue + }); + this.props.onTagUpdate(event, updatedTag); + } + } + + handleSuggestionsUpdateRequested = (suggestion) => { + if (this.props.onGetSuggestions) { + this.props.onGetSuggestions(suggestion); + } + } + + handleSuggestionsClearRequested = () => { + if (this.props.onClearSuggestions) { + this.props.onClearSuggestions(); + } + } + + /** + * Render a simple tag for display purposes only + * @return {JSX} simple tag + */ + renderTag() { + const url = Router.pathFor("tag", { + hash: { + slug: this.props.tag.slug + } + }); + + const baseClassName = classnames({ + "rui": true, + "tag": true, + "link": true, + "full-width": this.props.fullWidth + }); + + return ( + + {this.props.tag.name} + + ); + } + + /** + * Render an admin editable tag + * @return {JSX} editable tag + */ + renderEditableTag() { + const baseClassName = classnames({ + "rui": true, + "tag": true, + "edit": true, + "full-width": this.props.fullWidth + }); + + return ( + this.props.connectDropTarget( +
    +
    + + {this.renderAutosuggestInput()} +
    + ) + ); + } + + /** + * Render a tag creation form + * @return {JSX} blank tag for creating new tags + */ + renderBlankEditableTag() { + const baseClassName = classnames({ + "rui": true, + "tag": true, + "edit": true, + "create": true, + "full-width": this.props.fullWidth + }); + + return ( +
    +
    +
    + ); + } + + renderSuggestion(suggestion) { + return ( + {suggestion.label} + ); + } + + renderAutosuggestInput() { + return ( + + ); + } + + /** + * Render component + * @return {JSX} tag component + */ + render() { + if (this.props.editable) { + return this.renderEditableTag(); + } else if (this.props.blank) { + return this.renderBlankEditableTag(); + } + + return this.renderTag(); + } +} + +Tag.propTypes = { + blank: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + editable: PropTypes.bool, + fullWidth: PropTypes.bool, + i18nKeyInputPlaceholder: PropTypes.string, + index: PropTypes.number, + inputPlaceholder: PropTypes.string, + onGetSuggestions: PropTypes.func, + onTagInputBlur: PropTypes.func, + onTagMouseOut: PropTypes.func, + onTagMouseOver: PropTypes.func, + onTagRemove: PropTypes.func, + onTagSave: PropTypes.func, + onTagUpdate: PropTypes.func, + parentTag: PropTypes.object, + suggestions: PropTypes.arrayOf(PropTypes.object), + tag: PropTypes.object +}; + +export default SortableItem("tag", Tag); diff --git a/imports/plugins/core/ui/client/components/tags/tagItem.js b/imports/plugins/core/ui/client/components/tags/tagItem.js index 0facd3fb..9e618128 100644 --- a/imports/plugins/core/ui/client/components/tags/tagItem.js +++ b/imports/plugins/core/ui/client/components/tags/tagItem.js @@ -12,9 +12,12 @@ function createAutosuggestInput(templateInstance, options) { suggestions: templateInstance.state.get("suggestions"), getSuggestionValue: getSuggestionValue, renderSuggestion: renderSuggestion, - onSuggestionsUpdateRequested({ value }) { + onSuggestionsFetchRequested({ value }) { templateInstance.state.set("suggestions", getSuggestions(value)); }, + onSuggestionsClearRequested() { + templateInstance.state.set("suggestions", []); + }, inputProps: { placeholder: i18next.t(options.i18nPlaceholderKey, { defaultValue: options.i18nPlaceholderValue}), value: templateInstance.state.get("inputValue"), diff --git a/imports/plugins/core/ui/client/components/tags/tags.jsx b/imports/plugins/core/ui/client/components/tags/tags.jsx index 4d8541d5..bd3b6df8 100644 --- a/imports/plugins/core/ui/client/components/tags/tags.jsx +++ b/imports/plugins/core/ui/client/components/tags/tags.jsx @@ -1,260 +1,162 @@ -// /* eslint no-extra-parens: 0 */ -// import React from "react"; -// import { PropTypes } from "/lib/api"; -// const Tag = ReactionUI.Components.Tag; -// const classnames = ReactionUI.Lib.classnames; -// const Sortable = ReactionUI.Lib.Sortable; -// -// class Tags extends React.Component { -// displayName = "Tag List (Tags)"; -// -// constructor(props) { -// super(props); -// this.state = { -// isEditing: true, -// tags: props.tags, -// tagIds: props.tags.map((tag) => tag._id) -// }; -// } -// -// componentDidMount() { -// if (this.props.editable) { -// this._sortable = Sortable.create(this.refs.tags, { -// group: "tags", -// onSort: this.handleDragSort, -// onAdd: this.handleDragAdd, -// onRemove: this.handleDragRemove -// }); -// } -// } -// -// componentWillReceiveProps(props) { -// this.setState({ -// tags: this.props.tags, -// tagIds: this.props.tags.map((tag) => tag._id) -// }); -// -// if (props.editable && this.state.isEditing) { -// if (this._sortable) { -// // this._sortable.option("disabled", false); -// } else { -// this._sortable = Sortable.create(this.refs.tags, { -// group: "tags", -// onSort: this.handleDragSort, -// onAdd: this.handleDragAdd, -// onRemove: this.handleDragRemove -// }); -// } -// } -// } -// -// handleDragAdd = (event) => { -// const toListId = event.to.dataset.id; -// const movedTagId = event.item.dataset.id; -// -// this.setState({ -// tagIds: [ -// ...this.state.tagsIds, -// movedTagId -// ] -// }); -// -// if (this.props.onTagDragAdd) { -// this.props.onTagDragAdd(movedTagId, toListId, event.newIndex, this.props.tags); -// } -// }; -// -// handleDragRemove = (event) => { -// const movedTagId = event.item.dataset.id; -// -// if (this.props.onTagRemove) { -// let foundTag = _.find(this.props.tags, (tag) => { -// return tag._id === movedTagId; -// }); -// -// this.props.onTagRemove(foundTag, this.props.parentTag); -// } -// }; -// -// handleDragSort = (event) => { -// let newTagsOrder = this.move(this.state.tagIds, event.oldIndex, event.newIndex); -// -// if (newTagsOrder) { -// if (this.props.onTagSort) { -// this.props.onTagSort(newTagsOrder, this.props.parentTag); -// } -// } -// }; -// -// move(array, from, to) { -// let fromIndex = from; -// let toIndex = to; -// -// if (!_.isArray(array)) { -// return null; -// } -// -// while (fromIndex < 0) { -// fromIndex += array.length; -// } -// while (toIndex < 0) { -// toIndex += array.length; -// } -// if (toIndex >= this.length) { -// let k = toIndex - array.length; -// while ((k--) + 1) { -// array.push(undefined); -// } -// } -// -// array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); -// -// return array; -// } -// -// handleNewTagSubmit = (event) => { -// event.preventDefault(); -// if (this.props.onTagCreate) { -// this.props.onTagCreate(event.target.tag.value, this.props.parentTag); -// } -// }; -// -// handleTagCreate = (tagId, tagName) => { -// if (this.props.onTagCreate) { -// this.props.onTagCreate(tagId, tagName); -// } -// }; -// -// handleTagRemove = (tag) => { -// if (this.props.onTagRemove) { -// this.props.onTagRemove(tag, this.props.parentTag); -// } -// }; -// -// /** -// * Handle tag mouse out events and pass them up the component chain -// * @param {Event} event Event object -// * @param {Tag} tag Reaction.Schemas.Tag - a tag object -// * @return {void} no return value -// */ -// handleTagMouseOut = (event, tag) => { -// if (this.props.onTagMouseOut) { -// this.props.onTagMouseOut(event, tag); -// } -// }; -// -// /** -// * Handle tag mouse over events and pass them up the component chain -// * @param {Event} event Event object -// * @param {Tag} tag Reaction.Schemas.Tag - a tag object -// * @return {void} no return value -// */ -// handleTagMouseOver = (event, tag) => { -// if (this.props.onTagMouseOver) { -// this.props.onTagMouseOver(event, tag); -// } -// }; -// -// -// handleTagUpdate = (tagId, tagName) => { -// if (this.props.onTagUpdate) { -// let parentTagId; -// if (this.props.parentTag) { -// parentTagId = this.props.parentTag._id; -// } -// this.props.onTagUpdate(tagId, tagName, parentTagId); -// } -// }; -// -// handleTagBookmark = (event) => { -// event; -// // handle event -// }; -// -// renderTags() { -// if (_.isArray(this.state.tags)) { -// const tags = this.state.tags.map((tag, index) => { -// if (tag) { -// return ( -// -// ); -// } -// }); -// -// // Render an blank tag for creating new tags -// if (this.props.editable && this.props.enableNewTagForm) { -// tags.push( -// -// ); -// } -// -// return tags; -// } -// } -// -// render() { -// if (this.state.isEditing === false && this._sortable) { -// this._sortable.option("disabled", true); -// } -// -// const classes = classnames({ -// rui: true, -// tags: true, -// edit: this.props.editable -// }); -// -// return ( -//
    -// {this.renderTags()} -//
    -// ); -// } -// } -// -// // Default Props -// Tags.defaultProps = { -// parentTag: {} -// }; -// -// // Prop Types -// Tags.propTypes = { -// editable: React.PropTypes.bool, -// enableNewTagForm: React.PropTypes.bool, -// -// // Event handelers -// onTagBookmark: React.PropTypes.func, -// onTagCreate: React.PropTypes.func, -// onTagDragAdd: React.PropTypes.func, -// onTagMouseOut: React.PropTypes.func, -// onTagMouseOver: React.PropTypes.func, -// onTagRemove: React.PropTypes.func, -// onTagSort: React.PropTypes.func, -// onTagUpdate: React.PropTypes.func, -// -// parentTag: PropTypes.Tag, -// placeholder: React.PropTypes.string, -// showBookmark: React.PropTypes.bool, -// // tag: PropTypes.Tag -// tags: PropTypes.arrayOfTags -// }; -// -// // Export -// ReactionUI.Components.Tags = Tags; +import React, { Component, PropTypes } from "react"; +import { PropTypes as ReactionPropTypes } from "/lib/api"; +import { TagItem } from "./"; +import classnames from "classnames"; + +class Tags extends Component { + displayName = "Tag List (Tags)"; + + handleNewTagSave = (event, tag) => { + event.preventDefault(); + if (this.props.onNewTagSave) { + this.props.onNewTagSave(tag, this.props.parentTag); + } + }; + + handleNewTagUpdate = (event, tag) => { + if (this.props.onNewTagUpdate) { + this.props.onNewTagUpdate(tag, this.props.parentTag); + } + } + + handleTagSave = (event, tag) => { + if (this.props.onTagSave) { + this.props.onTagSave(tag, this.props.parentTag); + } + }; + + handleTagRemove = (tag) => { + if (this.props.onTagRemove) { + this.props.onTagRemove(tag, this.props.parentTag); + } + }; + + /** + * Handle tag mouse out events and pass them up the component chain + * @param {Event} event Event object + * @param {Tag} tag Reaction.Schemas.Tag - a tag object + * @return {void} no return value + */ + handleTagMouseOut = (event, tag) => { + if (this.props.onTagMouseOut) { + this.props.onTagMouseOut(event, tag, this.props.parentTag); + } + }; + + /** + * Handle tag mouse over events and pass them up the component chain + * @param {Event} event Event object + * @param {Tag} tag Reaction.Schemas.Tag - a tag object + * @return {void} no return value + */ + handleTagMouseOver = (event, tag) => { + if (this.props.onTagMouseOver) { + this.props.onTagMouseOver(event, tag, this.props.parentTag); + } + }; + + + handleTagUpdate = (event, tag) => { + if (this.props.onTagUpdate) { + this.props.onTagUpdate(tag, this.props.parentTag); + } + }; + + renderTags() { + if (_.isArray(this.props.tags)) { + const tags = this.props.tags.map((tag, index) => { + return ( + + ); + }); + + // Render an blank tag for creating new tags + if (this.props.editable && this.props.enableNewTagForm) { + tags.push( + + ); + } + + return tags; + } + + return null; + } + + render() { + const classes = classnames({ + rui: true, + tags: true, + edit: this.props.editable + }); + + return ( +
    + {this.renderTags()} +
    + ); + } +} + +// Default Props +Tags.defaultProps = { + parentTag: {} +}; + +// Prop Types +Tags.propTypes = { + editable: PropTypes.bool, + enableNewTagForm: PropTypes.bool, + newTag: PropTypes.object, + onClearSuggestions: PropTypes.func, + onGetSuggestions: PropTypes.func, + onMoveTag: PropTypes.func, + onNewTagSave: PropTypes.func, + onNewTagUpdate: PropTypes.func, + onTagMouseOut: PropTypes.func, + onTagMouseOver: PropTypes.func, + onTagRemove: PropTypes.func, + onTagSave: PropTypes.func, + onTagSort: PropTypes.func, + onTagUpdate: PropTypes.func, + parentTag: ReactionPropTypes.Tag, + showBookmark: PropTypes.bool, + suggestions: PropTypes.arrayOf(PropTypes.object), + tagProps: PropTypes.object, + tags: ReactionPropTypes.arrayOfTags +}; + +// Export +export default Tags; diff --git a/imports/plugins/core/ui/client/components/textfield/textfield.js b/imports/plugins/core/ui/client/components/textfield/textfield.js new file mode 100644 index 00000000..56afd313 --- /dev/null +++ b/imports/plugins/core/ui/client/components/textfield/textfield.js @@ -0,0 +1,172 @@ +import React, { Component, PropTypes} from "react"; +import classnames from "classnames"; +import TextareaAutosize from "react-textarea-autosize"; +import { Translation } from "../translation"; +import { i18next } from "/client/api"; + +class TextField extends Component { + /** + * Getter: value + * @return {String} value for text input + */ + get value() { + return this.props.value || ""; + } + + /** + * onValueChange + * @summary set the state when the value of the input is changed + * @param {Event} event Event object + * @return {void} + */ + onChange = (event) => { + if (this.props.onChange) { + this.props.onChange(event, event.target.value, this.props.name); + } + } + + /** + * onBlur + * @summary set the state when the value of the input is changed + * @param {Event} event Event object + * @return {void} + */ + onBlur = (event) => { + if (this.props.onBlur) { + this.props.onBlur(event, event.target.value, this.props.name); + } + } + + /** + * Render a multiline input (textarea) + * @return {JSX} jsx + */ + renderMultilineInput() { + const placeholder = i18next.t(this.props.i18nKeyPlaceholder, { + defaultValue: this.props.placeholder + }); + + return ( + + ); + } + + /** + * Render a singleline input + * @return {JSX} jsx + */ + renderSingleLineInput() { + const inputClassName = classnames({ + [`${this.props.name || "text"}-edit-input`]: true + }, this.props.className); + + const placeholder = i18next.t(this.props.i18nKeyPlaceholder, { + defaultValue: this.props.placeholder + }); + + return ( + + ); + } + + /** + * Render either a multiline (textarea) or singleline (input) + * @return {JSX} jsx template + */ + renderField() { + if (this.props.multiline === true) { + return this.renderMultilineInput(); + } + + return this.renderSingleLineInput(); + } + + renderLabel() { + if (this.props.label) { + return ( + + ); + } + + return null; + } + + renderHelpText() { + if (this.props.helpText) { + return ( + + + + ); + } + + return null; + } + + /** + * Render Component + * @return {JSX} component + */ + render() { + const classes = classnames({ + // Base + "rui": true, + "textfield": true, + "form-group": true, + + // Alignment + "center": this.props.align === "center", + "left": this.props.align === "left", + "right": this.props.align === "right" + }); + + return ( +
    + {this.renderLabel()} + {this.renderField()} + {this.renderHelpText()} + +
    + ); + } +} + +TextField.defaultProps = { + +}; + +TextField.propTypes = { + align: PropTypes.oneOf(["left", "center", "right", "justify"]), + className: PropTypes.string, + helpText: PropTypes.string, + i18nKeyHelpText: PropTypes.string, + i18nKeyLabel: PropTypes.string, + i18nKeyPlaceholder: PropTypes.string, + label: PropTypes.string, + multiline: PropTypes.bool, + name: PropTypes.string, + onBlur: PropTypes.func, + onChange: PropTypes.func, + placeholder: PropTypes.string, + value: PropTypes.string +}; + +export default TextField; diff --git a/imports/plugins/core/ui/client/components/textfield/textfield.jsx b/imports/plugins/core/ui/client/components/textfield/textfield.jsx deleted file mode 100644 index 4fff22d9..00000000 --- a/imports/plugins/core/ui/client/components/textfield/textfield.jsx +++ /dev/null @@ -1,144 +0,0 @@ -// // TODO: Place holder imports -// // import React from "react" -// const classnames = ReactionUI.Lib.classnames; -// const TextareaAutosize = ReactionUI.Lib.TextareaAutosize; -// -// class TextField extends React.Component { -// state = { -// value: "" -// } -// -// constructor(props) { -// super(props); -// -// this.state = { -// value: props.value -// }; -// } -// -// /** -// * onValueChange -// * @summary set the state when the value of the input is changed -// * @param {Event} event Event object -// * @return {void} -// */ -// onChange = (event) => { -// this.setState({ -// value: event.target.value -// }); -// -// if (this.props.onChange) { -// this.props.onChange(event); -// } -// } -// -// /** -// * onValueChange -// * @summary set the state when the value of the input is changed -// * @param {Event} event Event object -// * @return {void} -// */ -// onValueChange = (event) => { -// this.setState({ -// value: event.target.value -// }); -// -// if (this.props.onValueChange) { -// this.props.onValueChange(event); -// } -// } -// -// /** -// * componentWillReceiveProps - Component Lifecycle -// * @param {Object} props Properties passed from the parent component -// * @return {Void} no return value -// */ -// componentWillReceiveProps(props) { -// if (props) { -// this.setState({ -// value: props.value -// }); -// } -// } -// -// /** -// * Render a multiline input (textarea) -// * @return {JSX} jsx -// */ -// renderMultilineInput() { -// return ( -// -// ); -// } -// -// /** -// * Render a singleline input -// * @return {JSX} jsx -// */ -// renderSingleLineInput() { -// return ( -// -// ); -// } -// -// /** -// * Render either a multiline (textarea) or singleline (input) -// * @return {JSX} jsx template -// */ -// renderField() { -// if (this.props.multiline === true) { -// return this.renderMultilineInput(); -// } -// -// return this.renderSingleLineInput(); -// } -// -// /** -// * Render Component -// * @return {JSX} component -// */ -// render() { -// const classes = classnames({ -// // Base -// rui: true, -// textfield: true, -// -// // Alignment -// center: this.props.align === "center", -// left: this.props.align === "left", -// right: this.props.align === "right" -// }); -// -// return ( -//
    -// {this.renderField()} -// -//
    -// ); -// } -// } -// -// TextField.defaultProps = { -// align: "left" -// }; -// -// TextField.propTypes = { -// align: React.PropTypes.oneOf(["left", "center", "right", "justify"]) -// }; -// -// // Export -// ReactionUI.Components.TextField = TextField; diff --git a/imports/plugins/core/ui/client/components/toolbar/index.js b/imports/plugins/core/ui/client/components/toolbar/index.js new file mode 100644 index 00000000..517fe5bd --- /dev/null +++ b/imports/plugins/core/ui/client/components/toolbar/index.js @@ -0,0 +1,3 @@ +export { default as Toolbar } from "./toolbar"; +export { default as ToolbarGroup } from "./toolbarGroup"; +export { default as ToolbarText } from "./toolbarText"; diff --git a/imports/plugins/core/ui/client/components/toolbar/toolbar.js b/imports/plugins/core/ui/client/components/toolbar/toolbar.js new file mode 100644 index 00000000..8db5fccc --- /dev/null +++ b/imports/plugins/core/ui/client/components/toolbar/toolbar.js @@ -0,0 +1,25 @@ +import React, { Children, Component, PropTypes } from "react"; +import TetherComponent from "react-tether"; +import classnames from "classnames"; + + +class Toolbar extends Component { + render() { + return ( + + ); + } +} + +Toolbar.propTypes = { + attachment: PropTypes.string, + children: PropTypes.node, +}; + +Toolbar.defaultProps = { + attachment: "top" +}; + +export default Toolbar; diff --git a/imports/plugins/core/ui/client/components/toolbar/toolbarGroup.js b/imports/plugins/core/ui/client/components/toolbar/toolbarGroup.js new file mode 100644 index 00000000..9431647d --- /dev/null +++ b/imports/plugins/core/ui/client/components/toolbar/toolbarGroup.js @@ -0,0 +1,27 @@ +import React, { PropTypes } from "react"; +import classnames from "classnames"; + +/** + * Toobar Text + * @param {Object} props component props + * @return {node} react element node + */ +const ToolbarGroup = (props) => { + const baseClassName = classnames({ + "rui": true, + "toolbar-group": true, + "left": props.firstChild, + "right": props.lastChild + }, props.className); + + return ( +
    {props.children}
    + ); +}; + +ToolbarGroup.propTypes = { + children: PropTypes.node, + className: PropTypes.oneOfType([PropTypes.object, PropTypes.string]) +}; + +export default ToolbarGroup; diff --git a/imports/plugins/core/ui/client/components/toolbar/toolbarText.js b/imports/plugins/core/ui/client/components/toolbar/toolbarText.js new file mode 100644 index 00000000..ee7d1de8 --- /dev/null +++ b/imports/plugins/core/ui/client/components/toolbar/toolbarText.js @@ -0,0 +1,24 @@ +import React, { PropTypes } from "react"; +import classnames from "classnames"; + +/** + * Toobar Text + * @param {Object} props component props + * @return {node} react element node + */ +const ToolbarText = (props) => { + const baseClassName = classnames({ + "navbar-text": true + }, props.className); + + return ( +
    {props.children}
    + ); +}; + +ToolbarText.propTypes = { + children: PropTypes.node, + className: PropTypes.oneOfType([PropTypes.object, PropTypes.string]) +}; + +export default ToolbarText; diff --git a/imports/plugins/core/ui/client/components/tooltip/tooltip.js b/imports/plugins/core/ui/client/components/tooltip/tooltip.js new file mode 100644 index 00000000..8cccf2b0 --- /dev/null +++ b/imports/plugins/core/ui/client/components/tooltip/tooltip.js @@ -0,0 +1,62 @@ +import React, { Component, PropTypes } from "react"; +import TetherComponent from "react-tether"; +import classnames from "classnames"; + +class Tooltip extends Component { + + /** + * attachment + * @description Return the attachment for the tooltip or the default + * @return {String} attachment + */ + get attachment() { + return this.props.attachment || Tooltip.defaultProps.attachment; + } + + renderTooltip() { + if (this.props.tooltipContent) { + return ( +
    + {this.props.tooltipContent} +
    + ); + } + + return null; + } + + render() { + return ( + +
    + {this.props.children} +
    + {this.renderTooltip()} +
    + ); + } +} + +Tooltip.propTypes = { + attachment: PropTypes.string, + children: PropTypes.node, + tooltipContent: PropTypes.node +}; + +Tooltip.defaultProps = { + attachment: "bottom center" +}; + +export default Tooltip; diff --git a/imports/plugins/core/ui/client/components/translation/currency.js b/imports/plugins/core/ui/client/components/translation/currency.js new file mode 100644 index 00000000..bc302220 --- /dev/null +++ b/imports/plugins/core/ui/client/components/translation/currency.js @@ -0,0 +1,18 @@ +import React, { Component, PropTypes, Children } from "react"; // eslint-disable-line +import { formatPriceString } from "/client/api"; + +class Currency extends Component { + render() { + const amount = formatPriceString(this.props.amount); + + return ( + {amount} + ); + } +} + +Currency.propTypes = { + amount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) +}; + +export default Currency; diff --git a/imports/plugins/core/ui/client/components/translation/index.js b/imports/plugins/core/ui/client/components/translation/index.js new file mode 100644 index 00000000..202ff082 --- /dev/null +++ b/imports/plugins/core/ui/client/components/translation/index.js @@ -0,0 +1,2 @@ +export { default as Translation } from "./translation"; +export { default as Currency } from "./currency"; diff --git a/imports/plugins/core/ui/client/components/translation/translation.js b/imports/plugins/core/ui/client/components/translation/translation.js new file mode 100644 index 00000000..a1548e07 --- /dev/null +++ b/imports/plugins/core/ui/client/components/translation/translation.js @@ -0,0 +1,24 @@ +import { camelCase } from "lodash"; +import React, { Component, PropTypes } from "react"; +import { i18next } from "/client/api"; + +class Translation extends Component { + render() { + const i18nKey = this.props.i18nKey || camelCase(this.props.defaultValue); + + const translation = i18next.t(i18nKey, { + defaultValue: this.props.defaultValue + }); + + return ( + {translation} + ); + } +} + +Translation.propTypes = { + defaultValue: PropTypes.string, + i18nKey: PropTypes.string +}; + +export default Translation; diff --git a/imports/plugins/core/ui/client/containers/alertContainer.js b/imports/plugins/core/ui/client/containers/alertContainer.js new file mode 100644 index 00000000..3ba15779 --- /dev/null +++ b/imports/plugins/core/ui/client/containers/alertContainer.js @@ -0,0 +1,48 @@ +import React, { Component, PropTypes } from "react"; +import { composeWithTracker } from "react-komposer"; +import { Alerts } from "../components"; +import { default as ReactionAlerts } from "/imports/plugins/core/layout/client/templates/layout/alerts/inlineAlerts"; + +class AlertContainer extends Component { + handleAlertRemove(alert) { + ReactionAlerts.collection_.remove(alert._id); + } + + handleAlertSeen(alert) { + ReactionAlerts.collection_.update(alert._id, { + $set: { + seen: true + } + }); + } + + render() { + return ( +
    + +
    + ); + } +} + +function composer(props, onData) { + const alerts = ReactionAlerts.collection_.find({ + "options.placement": props.placement || "", + "options.id": props.id || "" + }).fetch(); + + onData(null, { + alerts: alerts + }); +} + +AlertContainer.propTypes = { + id: PropTypes.string, + placement: PropTypes.string +}; + +export default composeWithTracker(composer)(AlertContainer); diff --git a/imports/plugins/core/ui/client/containers/editContainer.js b/imports/plugins/core/ui/client/containers/editContainer.js new file mode 100644 index 00000000..112a0601 --- /dev/null +++ b/imports/plugins/core/ui/client/containers/editContainer.js @@ -0,0 +1,173 @@ +import React, { Children, Component, PropTypes } from "react"; +import { Reaction } from "/client/api"; +import { EditButton, VisibilityButton, Translation } from "/imports/plugins/core/ui/client/components"; +import { composeWithTracker } from "react-komposer"; + +class EditContainer extends Component { + + handleEditButtonClick = (event) => { + const props = this.props; + + if (this.props.onEditButtonClick) { + const returnValue = this.props.onEditButtonClick(event, props); + + if (returnValue === false) { + return returnValue; + } + } + + Reaction.showActionView({ + label: props.label, + i18nKeyLabel: props.i18nKeyLabel, + template: props.editView, + data: props.data + }); + + return true; + } + + handleVisibilityButtonClick = (event) => { + const props = this.props; + + if (this.props.onVisibilityButtonClick) { + const returnValue = this.props.onVisibilityButtonClick(event, props); + + if (returnValue === false) { + return returnValue; + } + } + + return true; + } + + renderVisibilityButton() { + if (this.props.showsVisibilityButton) { + return ( + + ); + } + + return null; + } + + renderEditButton() { + let status; + let tooltip; + let hasChange = false; + + if (this.props.data.__draft && this.props.field) { + const draft = this.props.data.__draft; + + if (Array.isArray(draft.diff)) { + for (const diff of draft.diff) { + let hasChangedField = false; + + if (Array.isArray(this.props.field)) { + if (this.props.field.indexOf(diff.path[0]) >= 0) { + hasChangedField = true; + } + } else if (typeof this.props.field === "string" && this.props.field === diff.path[0]) { + hasChangedField = true; + } + + if (hasChangedField) { + status = "warning"; + + tooltip = ( + + + + ); + + hasChange = true; + } + } + } + } else if (this.props.data.__draft) { + status = "warning"; + + tooltip = ( + + + + ); + } + + if (this.props.autoHideEditButton && hasChange === false) { + return null; + } + + return ( + + ); + } + + render() { + // Display edit button if the permissions allow it. + if (this.props.hasPermission) { + // If children were passed as props to this component, + // copy the children and inject the edit buttons + if (this.props.children) { + return React.cloneElement(this.props.children, { + visibilityButton: this.renderVisibilityButton(), + editButton: this.renderEditButton() + }); + } + + // Otherwise, render a container for the edit buttons + return ( + + {this.renderVisibilityButton()} + {this.renderEditButton()} + + ); + } + + // If permissions don't allow the edit buttons to be shown and there are + // no child elements, then cancel rendering. + if (!this.props.children) { + return null; + } + + // If permissions don't allow the edit buttons to be shown and there are + // child elements, render them normally + return ( + Children.only(this.props.children) + ); + } +} + +EditContainer.propTypes = { + autoHideEditButton: PropTypes.bool, + children: PropTypes.node, + data: PropTypes.object, + field: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + hasPermission: PropTypes.bool, + onEditButtonClick: PropTypes.func, + onVisibilityButtonClick: PropTypes.func, + showsVisibilityButton: PropTypes.bool +}; + +function composer(props, onData) { + let hasPermission; + const viewAs = Reaction.Router.getQueryParam("as"); + + if (props.disabled === true || viewAs === "customer") { + hasPermission = false; + } else { + hasPermission = Reaction.hasPermission(props.premissions); + } + + onData(null, { + hasPermission + }); +} + +export default composeWithTracker(composer)(EditContainer); diff --git a/imports/plugins/core/ui/client/containers/index.js b/imports/plugins/core/ui/client/containers/index.js new file mode 100644 index 00000000..4acd2988 --- /dev/null +++ b/imports/plugins/core/ui/client/containers/index.js @@ -0,0 +1,5 @@ +export { default as EditContainer } from "./editContainer"; +export { default as TagListContainer } from "./tagListContainer"; +export { default as AlertContainer } from "./alertContainer"; +export { default as SortableItem } from "./sortableItem"; +export { default as MediaGalleryContainer } from "./mediaGalleryContainer"; diff --git a/imports/plugins/core/ui/client/containers/mediaGalleryContainer.js b/imports/plugins/core/ui/client/containers/mediaGalleryContainer.js new file mode 100644 index 00000000..f0c1d735 --- /dev/null +++ b/imports/plugins/core/ui/client/containers/mediaGalleryContainer.js @@ -0,0 +1,179 @@ +import React, { Component, PropTypes } from "react"; +import update from "react/lib/update"; +import { composeWithTracker } from "react-komposer"; +import { MediaGallery } from "../components"; +import { Reaction } from "/client/api"; +import { ReactionProduct } from "/lib/api"; +import { Media } 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(); + // 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; + + for (const file of files) { + const fileObj = new FS.File(file); + + fileObj.metadata = { + ownerId: userId, + productId: productId, + variantId: variantId, + shopId: shopId, + priority: count, + toGrid: +toGrid // we need number + }; + + Media.insert(fileObj); + count++; + } + + return true; +} + +class MediaGalleryContainer extends Component { + state = { + featuredMedia: undefined + } + + handleDrop = (files) => { + uploadHandler(files); + } + + handleRemoveMedia = (media) => { + const imageUrl = media.url(); + const mediaId = media._id; + + Alerts.alert({ + title: "Remove Media?", + type: "warning", + showCancelButton: true, + imageUrl, + imageHeight: 150 + }, (isConfirm) => { + if (isConfirm) { + Media.remove({ _id: mediaId }, (error) => { + if (error) { + Alerts.toast(error.reason, "warning", { + autoHide: 10000 + }); + } + + // updateImagePriorities(); + }); + } + }); + } + + get media() { + return (this.state && this.state.media) || this.props.media; + } + + handleMouseEnterMedia = (event, media) => { + this.setState({ + featuredMedia: media + }); + } + + handleMouseLeaveMedia = () => { + this.setState({ + featuredMedia: undefined + }); + } + + handleMoveMedia = (dragIndex, hoverIndex) => { + const media = this.props.media[dragIndex]; + + // Apply new sort order to variant list + const newMediaOrder = update(this.props.media, { + $splice: [ + [dragIndex, 1], + [hoverIndex, 0, media] + ] + }); + + // 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 + }); + + // Save the updated positions + Meteor.defer(() => { + newMediaOrder.forEach((mediaItem, index) => { + Media.update(mediaItem._id, { + $set: { + "metadata.priority": index + } + }); + }); + }); + } + + render() { + return ( + + ); + } +} + +function composer(props, onData) { + let media; + let editable; + const viewAs = Reaction.Router.getQueryParam("as"); + + if (!props.media) { + // Fetch media based on props + } else { + media = props.media; + } + + if (viewAs === "customer") { + editable = false; + } else { + editable = Reaction.hasPermission(props.permission || ["createProduct"]); + } + + onData(null, { + editable, + media + }); +} + +MediaGalleryContainer.propTypes = { + editable: PropTypes.bool, + id: PropTypes.string, + media: PropTypes.arrayOf(PropTypes.object), + placement: PropTypes.string +}; + +export default composeWithTracker(composer)(MediaGalleryContainer); diff --git a/imports/plugins/core/ui/client/containers/sortableItem.js b/imports/plugins/core/ui/client/containers/sortableItem.js new file mode 100644 index 00000000..25e77ce8 --- /dev/null +++ b/imports/plugins/core/ui/client/containers/sortableItem.js @@ -0,0 +1,138 @@ +import React, { PropTypes } from "react"; +import { findDOMNode } from "react-dom"; +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 + * @param {DragSourceMonitor} monitor An object containing functions that return information about drag state + * @return {Object} Props for drag source + */ +function collectDropSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect) { + return { + connectDropTarget: connect.dropTarget() + }; +} + +const cardTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().index; + const hoverIndex = props.index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + + // Get horizontal middle + const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2; + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels from left + const hoverClientX = clientOffset.x - hoverBoundingRect.left; + + // Get pixels to the top + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // // Dragging to left + // // Don't update position if we are dragging an item to the [left], + // // but have not crossed the middle of the item we are dragging over + // if (dragIndex > hoverIndex && hoverClientX > hoverMiddleX) { + // return; + // } + // + // // Dragging to right + // // Don't update position if we are dragging an item to the [right], + // // but have not crossed the middle of the item we are dragging over + // if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX) { + // return; + // } + // + // + // // Dragging downwards + // if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + // return; + // } + // + // // Dragging upwards + // if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + // return; + // } + // + // Move up the list + // if (dragIndex > hoverIndex && (hoverClientX > hoverMiddleX && hoverClientY < hoverMiddleY)) { + // return; + // } + // + // // Move down the list + // if (dragIndex < hoverIndex && (hoverClientX < hoverMiddleX && hoverClientY > hoverMiddleY)) { + // return; + // } + + // + // + // console.log("should update"); + // return + + // Time to actually perform the action + props.onMove(dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + monitor.getItem().index = hoverIndex; + } +}; + +export default function ComposeSortableItem(itemType, SortableItemComponent) { + const SortableItem = (props) => { + return ; + }; + + SortableItem.contextTypes = { + dragDropManager: PropTypes.object.isRequired + }; + + SortableItem.propTypes = { + // Injected by React DnD: + connectDragSource: PropTypes.func.isRequired, + connectDropTarget: PropTypes.func.isRequired, + connectDragPreview: PropTypes.func.isRequired, + isDragging: PropTypes.bool.isRequired + }; + + let decoratedComponent = SortableItem; + decoratedComponent = DragSource(itemType, cardSource, collectDropSource)(decoratedComponent); + decoratedComponent = DropTarget(itemType, cardTarget, collectDropTarget)(decoratedComponent); + + return decoratedComponent; +} diff --git a/imports/plugins/core/ui/client/containers/tagListContainer.js b/imports/plugins/core/ui/client/containers/tagListContainer.js new file mode 100644 index 00000000..07f4290c --- /dev/null +++ b/imports/plugins/core/ui/client/containers/tagListContainer.js @@ -0,0 +1,276 @@ +import React, { Component, PropTypes } from "react"; +import debounce from "lodash/debounce"; +import update from "react/lib/update"; +import { Meteor } from "meteor/meteor"; +import { Reaction, i18next } from "/client/api"; +import { composeWithTracker } from "react-komposer"; +import { TagList } from "../components/tags"; +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); + + const selector = { + slug: new RegExp(slug, "i") + }; + + if (Array.isArray(excludeTags)) { + selector._id = { + $nin: excludeTags + }; + } + + const tags = Tags.find(selector).map((tag) => { + return { + label: tag.name + }; + }); + + return tags; +} + +class TagListContainer extends Component { + constructor(props) { + super(props); + + this.state = { + tagIds: props.tagIds || [], + tagsByKey: props.tagsByKey || {}, + newTag: { + name: "" + }, + suggestions: [] + }; + + this.debounceUpdateTagOrder = debounce(() => { + Meteor.call( + "products/updateProductField", + this.props.product._id, + "hashtags", + this.state.tagIds + ); + }, 500); + } + + componentWillReceiveProps(nextProps) { + this.setState({ + tagIds: nextProps.tagIds || [], + tagsByKey: nextProps.tagsByKey || {} + }); + } + + get productId() { + if (this.props.product) { + return this.props.product._id; + } + return null; + } + + canSaveTag(tag) { + // Blank tags cannot be saved + if (typeof tag.name === "string" && tag.name.trim().length === 0) { + return false; + } + + // If the tag does not have an id, then allow the save + if (!tag._id) { + return true; + } + + // Get the original tag from the props + // Tags from props are not mutated, and come from an outside source + const originalTag = this.props.tagsByKey[tag._id]; + + if (originalTag && originalTag.name !== tag.name) { + return true; + } + + return false; + } + + handleNewTagSave = (tag) => { + if (this.productId && this.canSaveTag(tag)) { + Meteor.call("products/updateProductTags", this.productId, tag.name, null, (error) => { + if (error) { + return Alerts.toast(i18next.t("productDetail.tagExists"), "error"); + } + + this.setState({ + newTag: { + name: "" + }, + suggestions: [] + }); + + return true; + }); + } + } + + handleNewTagUpdate = (tag) => { + this.setState({ + newTag: tag + }); + } + + handleTagSave = (tag) => { + if (this.productId && this.canSaveTag(tag)) { + Meteor.call("products/updateProductTags", this.productId, tag.name, tag._id, (error) => { + if (error) { + return Alerts.toast(i18next.t("productDetail.tagExists"), "error"); + } + + this.setState({ + suggestions: [] + }); + + return true; + }); + } + } + + handleTagRemove = (tag) => { + if (this.productId) { + Meteor.call("products/removeProductTag", this.productId, tag._id, (error) => { + if (error) { + Alerts.toast(i18next.t("productDetail.tagInUse"), "error"); + } + }); + } + } + + handleTagUpdate = (tag) => { + const newState = update(this.state, { + tagsByKey: { + [tag._id]: { + $set: tag + } + } + }); + + this.setState(newState); + } + + handleMoveTag = (dragIndex, hoverIndex) => { + const tag = this.state.tagIds[dragIndex]; + + // Apply new sort order to variant list + const newState = update(this.state, { + tagIds: { + $splice: [ + [dragIndex, 1], + [hoverIndex, 0, tag] + ] + } + }); + + // 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(newState, () => { + // Save the updated positions + if (this.props.product) { + this.debounceUpdateTagOrder(); + } + }); + } + + handleGetSuggestions = (suggestionUpdateRequest) => { + const suggestions = updateSuggestions( + suggestionUpdateRequest.value, + { excludeTags: this.state.tagIds } + ); + + this.setState({ + suggestions: suggestions + }); + } + + handleClearSuggestions = () => { + this.setState({ + suggestions: [] + }); + } + + get tags() { + if (this.props.editable) { + return this.state.tagIds.map((tagId) => this.state.tagsByKey[tagId]); + } + + return this.props.tagsAsArray; + } + + render() { + return ( + + + + ); + } +} + +TagListContainer.propTypes = { + children: PropTypes.node, + editable: PropTypes.bool, + hasPermission: PropTypes.bool, + product: PropTypes.object, + tagIds: PropTypes.arrayOf(PropTypes.string), + tagsAsArray: PropTypes.arrayOf(PropTypes.object), + tagsByKey: PropTypes.object +}; + +function composer(props, onData) { + let tags = props.tags; + + if (props.product) { + if (_.isArray(props.product.hashtags)) { + tags = _.map(props.product.hashtags, function (id) { + return Tags.findOne(id); + }); + } + } + + let isEditable = props.editable; + + if (typeof isEditable !== "boolean") { + isEditable = Reaction.hasPermission(props.premissions); + } + + const tagsByKey = {}; + + if (Array.isArray(tags)) { + for (const tag of tags) { + tagsByKey[tag._id] = tag; + } + } + + onData(null, { + isProductTags: props.product !== undefined, + tagIds: getTagIds({ tags }), + tagsByKey, + tagsAsArray: tags, + editable: isEditable + }); +} + +let decoratedComponent = TagListContainer; +decoratedComponent = composeWithTracker(composer)(decoratedComponent); + +export default decoratedComponent; diff --git a/imports/plugins/core/ui/client/providers/dragDropProvider.js b/imports/plugins/core/ui/client/providers/dragDropProvider.js new file mode 100644 index 00000000..8af6bf4f --- /dev/null +++ b/imports/plugins/core/ui/client/providers/dragDropProvider.js @@ -0,0 +1,45 @@ +import React, { Component, PropTypes, Children } from "react"; // eslint-disable-line +import { DragDropManager } from "dnd-core"; +import HTML5Backend from "react-dnd-html5-backend"; + +let defaultManager = new DragDropManager(HTML5Backend); + +// /** +// * This is singleton used to initialize only once dnd in our app. +// * If you initialized dnd and then try to initialize another dnd +// * context the app will break. +// * Here is more info: https://github.com/gaearon/react-dnd/issues/186 +// * +// * The solution is to call Dnd context from this singleton this way +// * all dnd contexts in the app are the same. +// */ +// export default function getDndContext() { +// if (defaultManager) return defaultManager; +// +// defaultManager = new DragDropManager(HTML5Backend); +// +// return defaultManager; +// } + +class DragDropProvider extends Component { + getChildContext() { + return { + dragDropManager: defaultManager + }; + } + + render() { + // `Children.only` enables us not to add a
    for nothing + return Children.only(this.props.children); + } +} + +DragDropProvider.childContextTypes = { + dragDropManager: PropTypes.object.isRequired +}; + +DragDropProvider.propTypes = { + children: PropTypes.node +}; + +export default DragDropProvider; diff --git a/imports/plugins/core/ui/client/providers/index.js b/imports/plugins/core/ui/client/providers/index.js new file mode 100644 index 00000000..76663a55 --- /dev/null +++ b/imports/plugins/core/ui/client/providers/index.js @@ -0,0 +1,3 @@ +export { default as Translatable } from "./translatable"; +export { default as TranslationProvider } from "./translationProvider"; +export { default as DragDropProvider } from "./dragDropProvider"; diff --git a/imports/plugins/core/ui/client/providers/translatable.js b/imports/plugins/core/ui/client/providers/translatable.js new file mode 100644 index 00000000..e81f8b28 --- /dev/null +++ b/imports/plugins/core/ui/client/providers/translatable.js @@ -0,0 +1,17 @@ +import React, { PropTypes } from "react"; + +export default function Translatable() { + return (Component) => { + const TranslatableComponent = (props, context) => { + const { translations } = context; + + return ; + }; + + TranslatableComponent.contextTypes = { + translations: PropTypes.object.isRequired + }; + + return TranslatableComponent; + }; +} diff --git a/imports/plugins/core/ui/client/providers/translationProvider.js b/imports/plugins/core/ui/client/providers/translationProvider.js new file mode 100644 index 00000000..ee502ced --- /dev/null +++ b/imports/plugins/core/ui/client/providers/translationProvider.js @@ -0,0 +1,36 @@ +import React, { Component, PropTypes, Children } from "react"; // eslint-disable-line +import { composeWithTracker } from "react-komposer"; +import { i18next, i18nextDep } from "/client/api"; + +class TranslationProvider extends Component { + getChildContext() { + const { translations } = this.props; + return { translations }; + } + render() { + // `Children.only` enables us not to add a
    for nothing + return Children.only(this.props.children); + } +} + +TranslationProvider.childContextTypes = { + translations: PropTypes.object.isRequired +}; + +TranslationProvider.propTypes = { + children: PropTypes.node, + translations: PropTypes.object.isRequired +}; + +function composer(props, onData) { + i18nextDep.depend(); + + onData(null, { + translations: { + language: Session.get("language") + } + }); +} + + +export default composeWithTracker(composer)(TranslationProvider); diff --git a/imports/plugins/core/versions/README.md b/imports/plugins/core/versions/README.md new file mode 100644 index 00000000..9e6171a3 --- /dev/null +++ b/imports/plugins/core/versions/README.md @@ -0,0 +1,5 @@ +# Note + +This is an experimental and preliminary implementation of migrations +so that we can roll out this feature. However this implementation may +(and probably will) change diff --git a/imports/plugins/core/versions/index.js b/imports/plugins/core/versions/index.js new file mode 100644 index 00000000..e14bb446 --- /dev/null +++ b/imports/plugins/core/versions/index.js @@ -0,0 +1 @@ +export { Migrations as Migrations } from "meteor/percolate:migrations"; diff --git a/imports/plugins/core/versions/register.js b/imports/plugins/core/versions/register.js new file mode 100644 index 00000000..85d437c4 --- /dev/null +++ b/imports/plugins/core/versions/register.js @@ -0,0 +1,10 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Migrations", + name: "reaction-migrations", + icon: "fa fa-database", + autoEnable: true, + settings: {}, + registry: [] +}); diff --git a/imports/plugins/core/versions/server/index.js b/imports/plugins/core/versions/server/index.js new file mode 100644 index 00000000..64e7a701 --- /dev/null +++ b/imports/plugins/core/versions/server/index.js @@ -0,0 +1,2 @@ +import "./startup"; +import "./migrations/"; 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 new file mode 100644 index 00000000..52e70777 --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/1_rebuild_account_and_order_search_collections.js @@ -0,0 +1,22 @@ +import { Migrations } from "/imports/plugins/core/versions"; +import { OrderSearch, AccountSearch } from "/lib/collections"; +import { buildOrderSearch, + buildAccountSearch } from "/imports/plugins/included/search-mongo/server/methods/searchcollections"; + +Migrations.add({ + version: 1, + up: function () { + OrderSearch.remove({}); + AccountSearch.remove(); + buildOrderSearch(); + buildAccountSearch(); + }, + down: function () { + // 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(); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/index.js b/imports/plugins/core/versions/server/migrations/index.js new file mode 100644 index 00000000..238d8131 --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/index.js @@ -0,0 +1 @@ +import "./1_rebuild_account_and_order_search_collections"; diff --git a/imports/plugins/core/versions/server/startup.js b/imports/plugins/core/versions/server/startup.js new file mode 100644 index 00000000..d0177b2e --- /dev/null +++ b/imports/plugins/core/versions/server/startup.js @@ -0,0 +1,20 @@ +import _ from "lodash"; +import { Hooks, Logger } from "/server/api"; +import { Migrations } from "/imports/plugins/core/versions"; + +function reactionLogger(opts) { + if (_.includes(["warn", "info", "error"], opts.level)) { + Logger[opts.level](opts.message); + } +} + +Migrations.config({ + logger: reactionLogger, + log: true, + logIfLatest: false, + collectionName: "Migrations" +}); + +Hooks.Events.add("afterCoreInit", () => { + Migrations.migrateTo("latest"); +}); diff --git a/imports/plugins/included/authnet/server/methods/authnet.js b/imports/plugins/included/authnet/server/methods/authnet.js index bbe81e9c..cf7fd4d3 100644 --- a/imports/plugins/included/authnet/server/methods/authnet.js +++ b/imports/plugins/included/authnet/server/methods/authnet.js @@ -6,7 +6,7 @@ import { Meteor } from "meteor/meteor"; import { check, Match } from "meteor/check"; import { Promise } from "meteor/promise"; -import AuthNetAPI from "authorize-net"; +import AuthNetAPI from "@reactioncommerce/authorize-net"; import { Reaction, Logger } from "/server/api"; import { Packages } from "/lib/collections"; import { PaymentMethod } from "/lib/collections/schemas"; diff --git a/imports/plugins/included/default-theme/client/styles/base.less b/imports/plugins/included/default-theme/client/styles/base.less index 76bb54bb..10b1b224 100644 --- a/imports/plugins/included/default-theme/client/styles/base.less +++ b/imports/plugins/included/default-theme/client/styles/base.less @@ -4,6 +4,10 @@ html, body { letter-spacing: @letter-spacing; } +body { + // margin-top: 50px; +} + main { min-height: 80vh; // padding: 20px 60px 135px 60px; @@ -77,7 +81,6 @@ h3 { a { cursor: pointer; - &:focus, &:hover { text-decoration: none; } diff --git a/imports/plugins/included/default-theme/client/styles/button.less b/imports/plugins/included/default-theme/client/styles/button.less index e8a66a62..efc4009f 100644 --- a/imports/plugins/included/default-theme/client/styles/button.less +++ b/imports/plugins/included/default-theme/client/styles/button.less @@ -22,13 +22,33 @@ justify-content: center; align-items: center; border-radius: 50px; - width: 24px; - height: 24px; + width: @btn-icon-size; + height: @btn-icon-size; color: @btn-edit-color; background-color: @btn-edit-bg; margin-left: 5px; } +// .rui.button.edit.btn-default, .btn-edit.btn-default { +// background-color: @btn-default-bg; +// } + +.rui.button.edit.btn-success, .btn-edit.btn-success { + background-color: @btn-success-bg; +} + +.rui.button.edit.btn-warning, .btn-edit.btn-warning { + background-color: @btn-warning-bg; +} + +.rui.button.edit.btn-danger, .btn-edit.btn-danger { + background-color: @btn-danger-bg; +} + +.rui.button.edit.btn-info, .btn-edit.btn-info { + background-color: @btn-info-bg; +} + .rui.button.edit > .icon, .btn-edit > .icon { display: flex; @@ -62,7 +82,6 @@ height: 24px; color: inherit; background-color: transparent; - // margin-left: 5px; } .rui.button.round, .btn-round { @@ -100,3 +119,18 @@ color: @black30; } } + +.rui.button.icon-only i { + width: 100%; +} + +.btn-flat { + .btn-link(); +} + +.btn-flat:hover, +.btn-flat:active, +.btn-flat:focus, +.btn-flat:visited { + text-decoration: none; +} diff --git a/imports/plugins/included/default-theme/client/styles/dropdowns.less b/imports/plugins/included/default-theme/client/styles/dropdowns.less index e39ffa22..658229f8 100644 --- a/imports/plugins/included/default-theme/client/styles/dropdowns.less +++ b/imports/plugins/included/default-theme/client/styles/dropdowns.less @@ -26,3 +26,10 @@ display: block; padding: 3px 20px; } + + +.rui.dropdown-menu { + display: block; + position: static; + float: none; +} diff --git a/imports/plugins/included/default-theme/client/styles/main.less b/imports/plugins/included/default-theme/client/styles/main.less index 38e499c2..134f708a 100644 --- a/imports/plugins/included/default-theme/client/styles/main.less +++ b/imports/plugins/included/default-theme/client/styles/main.less @@ -73,6 +73,7 @@ @import "grid.less"; @import "items.less"; @import "media.less"; +@import "menu.less"; @import "metadata.less"; @import "mixins.less"; @import "navbar.less"; @@ -90,7 +91,7 @@ @import "tagTree.less"; @import "textfield.less"; @import "themeEditor.less"; -@import "tooltip.less"; +@import "toolbar.less"; @import "tooltip.less"; @import "variables.less"; @@ -138,3 +139,5 @@ // Search @import "search/dashboard.less"; @import "search/results.less"; +@import "search/search-type-toggle.less"; +@import "search/sortable-table.less"; diff --git a/imports/plugins/included/default-theme/client/styles/media.less b/imports/plugins/included/default-theme/client/styles/media.less index bf3c4f13..0c82c854 100644 --- a/imports/plugins/included/default-theme/client/styles/media.less +++ b/imports/plugins/included/default-theme/client/styles/media.less @@ -1,3 +1,121 @@ +/* media gellery */ +.rui.media-gallery { + max-width: 100%; + text-align: center; + min-height: 150px !important; + max-height: 100%; + border-radius: 6px; + margin-top: 5px; + margin-bottom: 20px; + position: relative; +} + +.gallery-drop-pane .mainImg { + display:block; + top:0; + left:0; + padding:2px; + margin:0; + width:100%; + height:100%; +} + +.rui.media-gallery { + list-style: none; + padding: 0; + width: 100%; +} + +.rui.media-gallery .rui.badge-container { + position: absolute; + top: @badge-offset; + right: @badge-offset; + z-index: 1; +} + +.rui.media-gallery .draggable-media:first-child, +.rui.media-gallery .gallery-image:first-child { + display: block; + top: 0; + left: 0; + padding: 2px; + margin: 0; + width: 100%; + height: 100%; + pointer: auto; +} + +.rui.media-gallery .draggable-media { + display: inline-block; + width: 24%; + height: 24%; +} + +.rui.media-gallery .gallery-image { + position: relative; + display: inline-block; + top: 0; + left: 0; + padding: 2px; + margin: 0; + width: 24%; + height: 24%; + background-color: @list-group-hover-bg; +} + +.rui.media-gallery .gallery-image.gallery-tools { + visibility: hidden; + padding: 10px; + position:absolute; + bottom:0px; + right:0px; +} + +.rui.media-gallery .gallery-image.progress { + position: relative; + height: 5px; + top: -10px; + margin-left: 10px; + margin-right: 10px; + margin-bottom: 0px; + display: block; +} + +.rui.media-gallery .gallery-image:hover { + background-color: @list-group-hover-bg; + opacity: 0.7; + cursor: pointer; +} +.rui.media-gallery .gallery-image:hover .gallery-tools { + visibility: visible; + opacity: 1; +} + +.rui.media-gallery .gallery-image:hover .gallery-tools a { + color: @gray-dark; +} + +.rui.media-gallery .gallery-image .video { + width: 100%; +} + +.rui.media-gallery .gallery-image.add { + position: relative; +} + +.rui.media-gallery .gallery-image.add .badge-container { + position: absolute;; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + padding: 2px; + margin: 0; + width: 100%; + height: 100%; +} + .rui.media img { width: 100%; diff --git a/imports/plugins/included/default-theme/client/styles/menu.less b/imports/plugins/included/default-theme/client/styles/menu.less new file mode 100644 index 00000000..554710e2 --- /dev/null +++ b/imports/plugins/included/default-theme/client/styles/menu.less @@ -0,0 +1,16 @@ +.rui.menu .divider { + margin-top: 10px; + margin-bottom: 10px; +} + +.rui.menu-item .font-icon { + .margin-right(10px); +} + +.rui.menu-item.disabled { + color: @black30; +} + +.rui.menu-item.disabled:hover { + background-color: transparent; +} diff --git a/imports/plugins/included/default-theme/client/styles/metadata.less b/imports/plugins/included/default-theme/client/styles/metadata.less index 4f337f4a..882bba08 100644 --- a/imports/plugins/included/default-theme/client/styles/metadata.less +++ b/imports/plugins/included/default-theme/client/styles/metadata.less @@ -4,55 +4,179 @@ } .rui.meta-item { + display: flex; flex: 0 0 auto; width: 100%; - border-bottom: 1px solid @border-color; } -.rui.meta-item:first-child { - border-top: 1px solid @border-color; +.rui.meta-key { + width: 50%; + .padding-right(10px); + border-right: 1px solid @border-color; + width: 50%; + padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal; + background-color: @white; + border: 1px solid @border-color; + border-right: none; + border-bottom: none; +} + +.rui.meta-value { + width: 50%; + padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal; + background-color: @white; + border: 1px solid @border-color; + border-bottom: none; } -.rui.meta-item:first-child button { + + + +.rui.meta-item:first-child .meta-key { + border-radius: @btn-border-radius-base 0 0 0; +} + +.rui.meta-item:first-child .meta-value { border-radius: 0 @btn-border-radius-base 0 0; } -.rui.meta-item button { - border-radius: 0; - flex: 0 0 auto; + +.rui.meta-item:last-child .meta-key { + border-radius: 0 0 0 @btn-border-radius-base; + border-bottom: 1px solid @border-color; } -.rui.meta-item:last-child button { +.rui.meta-item:last-child .meta-value { border-radius: 0 0 @btn-border-radius-base 0; + border-bottom: 1px solid @border-color; +} + +.rui.metafield-list-item, +.rui.metafield-new-item { + padding: 0; + background-color: transparent; + + .row { + padding: 5px 3px 5px; + } + + border: none; + cursor: pointer; + + form { + display: flex; + } + + input { + flex: 1 1 auto; + border-radius: 0; + border: none; + box-shadow: none; + border: 1px solid @border-color; + border-right: none; + width: auto; + min-width: 0; + } + + button { + flex: 0 0 auto; + height: 100%; + width: 44px; + border-radius: 0; + background-color: @white; + border: 1px solid @border-color; + } } -.rui.metadata.edit { - // border: 1px solid @border-color; + + +// First item +.rui.metafield-list-item:first-child .metafield-key-input { + border-radius: @border-radius-base 0 0 0; } -.rui.metadata.edit form { - display: flex; - width: 100%; +.rui.metafield-list-item:first-child .metafield-value-input { + border-radius: 0; + border-left: none; } +.rui.metafield-list-item:first-child button { + border-radius: 0 @border-radius-base 0 0; + border-left: none; +} +// Middle Items +.rui.metafield-list-item .metafield-key-input { + border-radius: 0; +} -.rui.metadata.edit input { - flex: 1 1 auto; - border: none; - border-right: 1px solid @border-color; +.rui.metafield-list-item .metafield-value-input { + border-radius: 0; + border-left: none; +} + +.rui.metafield-list-item button { + border-left: none; +} + +// Last Item +.rui.metafield-list-item:last-child .metafield-key-input { + border-radius: 0 0 0 @border-radius-base; +} + +.rui.metafield-list-item:last-child .metafield-value-input { border-radius: 0; + border-left: none; +} + +.rui.metafield-list-item:last-child button { + border-radius: 0 0 @border-radius-base 0; + border-left: none; +} + +html.rtl { + // First item + .rui.metafield-list-item:first-child .metafield-key-input { + border-radius: 0 @border-radius-base 0 0; + } + + .rui.metafield-list-item:first-child .metafield-value-input { + border-radius: 0; + border-right: none; + } - &:first-child { + .rui.metafield-list-item:first-child button { + border-radius: @border-radius-base 0 0 0; border-left: 1px solid @border-color; } -} -.rui.metadata.edit { + // Middle Items + .rui.metafield-list-item .metafield-key-input { + border-radius: 0; + } - button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + .rui.metafield-list-item .metafield-value-input { + border-radius: 0; + border-right: none; + } + + .rui.metafield-list-item button { + border-left: 1px solid @border-color; + } + + // Last Item + .rui.metafield-list-item:last-child .metafield-key-input { + border-radius: 0 0 @border-radius-base 0; + } + + .rui.metafield-list-item:last-child .metafield-value-input { + border-radius: 0; + border-right: none; + } + + .rui.metafield-list-item:last-child button { + border-radius: 0 0 0 @border-radius-base; + border-left: 1px solid @border-color; } } diff --git a/imports/plugins/included/default-theme/client/styles/popover.less b/imports/plugins/included/default-theme/client/styles/popover.less index 25ccea89..52f7389e 100644 --- a/imports/plugins/included/default-theme/client/styles/popover.less +++ b/imports/plugins/included/default-theme/client/styles/popover.less @@ -1,28 +1,32 @@ -.drop-element, -.drop-element:after, -.drop-element:before, -.drop-element *, -.drop-element *:after, -.drop-element *:before { +.popover-element, +.popover-element:after, +.popover-element:before, +.popover-element *, +.popover-element *:after, +.popover-element *:before { box-sizing: border-box; } -.drop-element { +.popover-element { position: absolute; display: none; z-index: @zindex-popover; } -.drop-element.drop-open { +.rui.popover-content { + padding: 0; +} + +.popover-element.popover-open { display: block; } -.drop-element.drop-theme-arrows { +.popover-element.popover-theme-arrows { max-width: 100%; max-height: 100%; } -.drop-element.drop-theme-arrows .drop-content { +.popover-element.popover-theme-arrows .popover-content { border-radius: 5px; position: relative; font-family: inherit; @@ -33,10 +37,10 @@ line-height: 1.5em; -webkit-transform: translateZ(0); transform: translateZ(0); - -webkit-filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.2)); - filter:drop-shadow(0 1px 4px rgba(0, 0, 0, 0.2)); + -webkit-filter: popover-shadow(0 1px 4px rgba(0, 0, 0, 0.2)); + filter:popover-shadow(0 1px 4px rgba(0, 0, 0, 0.2)); } -.drop-element.drop-theme-arrows .drop-content:before { +.popover-element.popover-theme-arrows .popover-content:before { content: ""; display: block; position: absolute; @@ -46,102 +50,102 @@ border-width: 16px; border-style: solid; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-center .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-center .popover-content { margin-bottom: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-center .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-center .popover-content:before { top: 100%; left: 50%; margin-left: -16px; border-top-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-center .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-center .popover-content { margin-top: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-center .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-center .popover-content:before { bottom: 100%; left: 50%; margin-left: -16px; border-bottom-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-right.drop-element-attached-middle .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-right.popover-element-attached-middle .popover-content { margin-right: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-right.drop-element-attached-middle .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-right.popover-element-attached-middle .popover-content:before { left: 100%; top: 50%; margin-top: -16px; border-left-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-left.drop-element-attached-middle .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-left.popover-element-attached-middle .popover-content { margin-left: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-left.drop-element-attached-middle .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-left.popover-element-attached-middle .popover-content:before { right: 100%; top: 50%; margin-top: -16px; border-right-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-left.drop-target-attached-bottom .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-left.popover-target-attached-bottom .popover-content { margin-top: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-left.drop-target-attached-bottom .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-left.popover-target-attached-bottom .popover-content:before { bottom: 100%; left: 16px; border-bottom-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-right.drop-target-attached-bottom .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-right.popover-target-attached-bottom .popover-content { margin-top: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-right.drop-target-attached-bottom .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-right.popover-target-attached-bottom .popover-content:before { bottom: 100%; right: 16px; border-bottom-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-left.drop-target-attached-top .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-left.popover-target-attached-top .popover-content { margin-bottom: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-left.drop-target-attached-top .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-left.popover-target-attached-top .popover-content:before { top: 100%; left: 16px; border-top-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-right.drop-target-attached-top .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-right.popover-target-attached-top .popover-content { margin-bottom: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-right.drop-target-attached-top .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-right.popover-target-attached-top .popover-content:before { top: 100%; right: 16px; border-top-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-right.drop-target-attached-left .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-right.popover-target-attached-left .popover-content { margin-right: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-right.drop-target-attached-left .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-right.popover-target-attached-left .popover-content:before { top: 16px; left: 100%; border-left-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-left.drop-target-attached-right .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-left.popover-target-attached-right .popover-content { margin-left: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-top.drop-element-attached-left.drop-target-attached-right .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-top.popover-element-attached-left.popover-target-attached-right .popover-content:before { top: 16px; right: 100%; border-right-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-right.drop-target-attached-left .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-right.popover-target-attached-left .popover-content { margin-right: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-right.drop-target-attached-left .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-right.popover-target-attached-left .popover-content:before { bottom: 16px; left: 100%; border-left-color: #eee; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-left.drop-target-attached-right .drop-content { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-left.popover-target-attached-right .popover-content { margin-left: 16px; } -.drop-element.drop-theme-arrows.drop-element-attached-bottom.drop-element-attached-left.drop-target-attached-right .drop-content:before { +.popover-element.popover-theme-arrows.popover-element-attached-bottom.popover-element-attached-left.popover-target-attached-right .popover-content:before { bottom: 16px; right: 100%; border-right-color: #eee; diff --git a/imports/plugins/included/default-theme/client/styles/products/attributes.less b/imports/plugins/included/default-theme/client/styles/products/attributes.less index fc54087e..3517fd5c 100644 --- a/imports/plugins/included/default-theme/client/styles/products/attributes.less +++ b/imports/plugins/included/default-theme/client/styles/products/attributes.less @@ -1,4 +1,5 @@ -.pdp-container { +// Legacy styles for old, Blaze, product detail page +.pdp { .metafield-list-item, .metafield-new-item { padding: 0; background-color: transparent; @@ -71,38 +72,39 @@ .product-attributes { padding: 0; } -} -html.rtl .pdp-container { - .metafield-list-item, .metafield-new-item { - input { - border: 1px solid @border-color; - border-left: none; - } - button { - border-left: 1px solid @border-color; - } - } + html.rtl { + .metafield-list-item, .metafield-new-item { + input { + border: 1px solid @border-color; + border-left: none; + } - .metafield-list-item:first-child { - input:first-child { - border-radius: 0 @border-radius-base 0 0; + button { + border-left: 1px solid @border-color; + } } - button { - border-radius: @border-radius-base 0 0 0; - } + .metafield-list-item:first-child { + input:first-child { + border-radius: 0 @border-radius-base 0 0; + } - } + button { + border-radius: @border-radius-base 0 0 0; + } - .metafield-list-item:last-child { - input:first-child { - border-radius: 0 0 @border-radius-base 0; } - button { - border-radius: 0 0 0 @border-radius-base; + .metafield-list-item:last-child { + input:first-child { + border-radius: 0 0 @border-radius-base 0; + } + + button { + border-radius: 0 0 0 @border-radius-base; + } } } } diff --git a/imports/plugins/included/default-theme/client/styles/products/productDetail.less b/imports/plugins/included/default-theme/client/styles/products/productDetail.less index 2f674672..14bc1ba6 100644 --- a/imports/plugins/included/default-theme/client/styles/products/productDetail.less +++ b/imports/plugins/included/default-theme/client/styles/products/productDetail.less @@ -1,4 +1,6 @@ - +.pdp-container { + max-width: 1440px; +} .pdp-content { display: flex; @@ -14,11 +16,12 @@ } // Header -.php.header { +.pdp.header { text-align: center; } -.pdp.header h1 { +.pdp.header h1, +.pdp.header .title-edit-input { text-align: center; font-family: @headings-font-family-h1; font-size: @product-title-font-size; @@ -26,7 +29,8 @@ color: @headings-color-h1; } -.pdp.header h2 { +.pdp.header h2, +.pdp.header .pageTitle-edit-input { text-align: center; font-family: @headings-font-family-h2; font-size: @product-page-title-font-size; @@ -34,6 +38,17 @@ color: @headings-color-h2; } +// Product edit fields +.pdp.product-detail-edit { + position: relative; +} + +.pdp.product-detail-edit .edit-controls { + position: absolute; + top: -@btn-icon-size / 2; + right: -@btn-icon-size / 2; +} + @media only screen and (max-width: @screen-xs-max) { .pdp.header h1 { font-size: 30px; @@ -89,8 +104,11 @@ } } - - +.pdp .rui.social-buttons { + display: flex; + flex: 0 0 auto; + align-items: center; +} // Right Column // @@ -152,6 +170,12 @@ padding: 5px; } +.pdp .tags-header.edit, +.pdp .meta-header.edit { + display: flex; + align-items: center; +} + // Social .pdp .social-media { position: relative; diff --git a/imports/plugins/included/default-theme/client/styles/products/productGrid.less b/imports/plugins/included/default-theme/client/styles/products/productGrid.less index bcdf4ec5..7f5a9607 100644 --- a/imports/plugins/included/default-theme/client/styles/products/productGrid.less +++ b/imports/plugins/included/default-theme/client/styles/products/productGrid.less @@ -136,12 +136,6 @@ padding: 5px; } - .badge { - position: absolute; - top: 20px; - .left(19px); - } - @media @tablet { .product-grid-item-images { height: 225px; diff --git a/imports/plugins/included/default-theme/client/styles/products/productImageGallery.less b/imports/plugins/included/default-theme/client/styles/products/productImageGallery.less index 7f0789b3..a68ee8d6 100644 --- a/imports/plugins/included/default-theme/client/styles/products/productImageGallery.less +++ b/imports/plugins/included/default-theme/client/styles/products/productImageGallery.less @@ -1,3 +1,4 @@ +// Legacy product detail page media gallery /* image gallery select/reorder/upload */ .galleryDropPane { max-width: 100%; diff --git a/imports/plugins/included/default-theme/client/styles/products/variant.less b/imports/plugins/included/default-theme/client/styles/products/variant.less index dc60f8ba..96b1534c 100644 --- a/imports/plugins/included/default-theme/client/styles/products/variant.less +++ b/imports/plugins/included/default-theme/client/styles/products/variant.less @@ -70,3 +70,7 @@ background-color: @black20; margin-left: 5px; } + +.variant-deleted { + opacity: 0.8; +} diff --git a/imports/plugins/included/default-theme/client/styles/products/variantList.less b/imports/plugins/included/default-theme/client/styles/products/variantList.less index 032aacb3..a6464122 100644 --- a/imports/plugins/included/default-theme/client/styles/products/variantList.less +++ b/imports/plugins/included/default-theme/client/styles/products/variantList.less @@ -12,7 +12,7 @@ position: relative; } -.variant-select-option .variant-edit { +.variant-select-option .variant-controls { position: absolute; top: 0; right: 0; @@ -22,9 +22,5 @@ display: flex; justify-content: center; align-items: center; - border-radius: 50px; - width: 24px; - height: 24px; - background-color: @black20; margin-left: 5px; } diff --git a/imports/plugins/included/default-theme/client/styles/search/results.less b/imports/plugins/included/default-theme/client/styles/search/results.less index b285014c..12089497 100644 --- a/imports/plugins/included/default-theme/client/styles/search/results.less +++ b/imports/plugins/included/default-theme/client/styles/search/results.less @@ -22,7 +22,6 @@ /* -------------------------- Modal Header -------------------------- */ .rui.search-modal-header { width: 100%; - max-height: 250px; padding-top: 40px; padding-bottom: 40px; background: @white; diff --git a/imports/plugins/included/default-theme/client/styles/search/search-type-toggle.less b/imports/plugins/included/default-theme/client/styles/search/search-type-toggle.less new file mode 100644 index 00000000..a91d5b40 --- /dev/null +++ b/imports/plugins/included/default-theme/client/styles/search/search-type-toggle.less @@ -0,0 +1,27 @@ +.rui.search-type-toggle { + width: 96%; + height: auto; + cursor: pointer; + .display(flex); + border-bottom: solid 1px @black20; + + .search-type-option { + flex: 1; + text-align: center; + padding-top: 10px; + padding-bottom: 7px; + text-transform: uppercase; + border-bottom: 3px solid transparent; + transition: border-color 200ms linear; + + &:hover { + border-bottom: 3px solid fade(@brand-primary-color, 40%); + } + + &.search-type-active { + border-bottom: 3px solid @brand-primary-color; + transition: border-color 200ms linear; + } + + } +} diff --git a/imports/plugins/included/default-theme/client/styles/search/sortable-table.less b/imports/plugins/included/default-theme/client/styles/search/sortable-table.less new file mode 100644 index 00000000..6d1ad15d --- /dev/null +++ b/imports/plugins/included/default-theme/client/styles/search/sortable-table.less @@ -0,0 +1,166 @@ +/* -------------------------- Full Modal -------------------------- */ +.data-table { + width: 96%; + padding-top: 20px; + padding-bottom: 20px; + padding-left: 20px; + padding-right: 20px; + margin-left: auto; + margin-right: auto; + background: @black10; + + thead { + font-weight: bold; + + th { + cursor: pointer; + } + } + + .taco-table { + border-collapse: collapse; + border-spacing: 0; + margin-bottom: 22px; + max-width: 100%; + } + + .taco-table * { + box-sizing: border-box; + } + + .taco-table.table-full-width { + width: 100%; + } + + .taco-table.table-not-full-width { + width: auto; + } + + .taco-table td, + .taco-table th { + padding-top: 8px; + padding-bottom: 8px; + padding-left: 8px; + padding-right: 20px; + line-height: 1.42857; + text-align: left; + } + + .taco-table th { + vertical-align: bottom; + } + + .taco-table td { + vertical-align: top; + } + + .taco-table.table-striped > tbody > tr:nth-child(odd) { + background-color: rgba(0, 0, 0, 0.03); + } + + .taco-table th.sortable { + cursor: pointer; + } + + .taco-table th.sortable:hover { + background-color: rgba(0, 0, 0, 0.03); + } + + .taco-table th.sortable.sorted { + background-color: #f2f7fd; + position: relative; + } + + .taco-table > tbody > tr.row-highlight, + .taco-table.table-striped > tbody > tr:nth-child(odd).row-highlight { + background-color: #eee; + } + + .taco-table .column-highlight { + background-color: rgba(0, 0, 0, 0.05); + } + + .taco-table .data-type-Number, + .taco-table .data-type-NumberOrdinal { + // text-align: right; + } + + .taco-table .group-header { + border-left: 1px solid rgba(0, 0, 0, 0.06); + border-right: 1px solid rgba(0, 0, 0, 0.06); + } + + .taco-table .group-first { + border-left: 1px solid rgba(0, 0, 0, 0.06); + } + + .taco-table .group-last { + border-right: 1px solid rgba(0, 0, 0, 0.06); + } + + .taco-table .sort-indicator.sort-ascending:after { + // content: '▲'; + font-family: FontAwesome; + content: "\f106"; + } + + .taco-table .sort-indicator.sort-descending:after { + // content: '▼'; + font-family: FontAwesome; + content: "\f107"; + } + + .taco-table .sort-indicator:after { + position: absolute; + right: 4px; + top: -1.5px; + } + + .taco-table .highlight-max, + .taco-table .highlight-min { + font-weight: bold; + } + + .taco-table > tbody.bottom-data { + background-color: #f4f4f4; + border-top: 1px solid #ccc; + } + + + .taco-table td.shipping-status span { + + border-radius: 5px; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 8px; + padding-right: 8px; + + &.shipped { + background: #fe8163; + color: #fff; + } + &.packed { + background: #dd5b42; + color: #fff; + } + &.new { + background: #64e192; + color: #fff; + } + + } + + .taco-table td.account-manage span { + + border-radius: 5px; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 8px; + padding-right: 8px; + background: #64e192; + color: #fff; + cursor: pointer; + + } + +} diff --git a/imports/plugins/included/default-theme/client/styles/tags.less b/imports/plugins/included/default-theme/client/styles/tags.less index f7595048..f3c3e0df 100644 --- a/imports/plugins/included/default-theme/client/styles/tags.less +++ b/imports/plugins/included/default-theme/client/styles/tags.less @@ -12,6 +12,10 @@ margin: 5px; } +.rui.tag.full-width { + width: 100%; +} + .rui.tag.edit { height: 33px; padding: 0; @@ -29,6 +33,7 @@ } .rui.tag.edit input { + width: 100%; height: 100%; padding: @padding-base-vertical @padding-base-horizontal; border-radius: 0; @@ -38,7 +43,7 @@ border-left: none; } -.rui.tag.edit button { +.rui.tag.edit .btn { border-radius: 0; border: 1px solid @border-color; border-left: none; @@ -46,23 +51,23 @@ color: @text-color; } -.rui.tag.edit button:first-child { +.rui.tag.edit .btn:first-child { border-left: 1px solid @border-color; border-radius: @border-radius-base 0 0 @border-radius-base; } -html.rtl .rui.tag.edit button:first-child { +html.rtl .rui.tag.edit .btn:first-child { border-left: none; border-right: 1px solid @border-color; border-radius: 0 @border-radius-base @border-radius-base 0; } -.rui.tag.edit button:last-child { +.rui.tag.edit .btn:last-child { border-right: 1px solid @border-color; border-radius: 0 @border-radius-base @border-radius-base 0; } -html.rtl .rui.tag.edit button:last-child { +html.rtl .rui.tag.edit .btn:last-child { border-left: 1px solid @border-color; border-radius: @border-radius-base 0 0 @border-radius-base; } diff --git a/imports/plugins/included/default-theme/client/styles/textfield.less b/imports/plugins/included/default-theme/client/styles/textfield.less index af47dec7..772efe66 100644 --- a/imports/plugins/included/default-theme/client/styles/textfield.less +++ b/imports/plugins/included/default-theme/client/styles/textfield.less @@ -1,12 +1,13 @@ .rui.textfield { - display: flex; - flex: 1 1 auto; - border: none; + // display: flex; + // flex: 1 1 auto; + // border: none; } .rui.textfield input { - flex: 1 1 auto; + // flex: 1 1 auto; + width: 100%; padding: @padding-base-vertical @padding-base-horizontal; border: 1px solid @border-color; border-radius: @input-border-radius; @@ -33,7 +34,8 @@ } .rui.textfield textarea { - flex: 1 1 auto; + // flex: 1 1 auto; + width: 100%; padding: @padding-base-vertical @padding-base-horizontal; border: 1px solid @border-color; border-radius: @input-border-radius; diff --git a/imports/plugins/included/default-theme/client/styles/toolbar.less b/imports/plugins/included/default-theme/client/styles/toolbar.less new file mode 100644 index 00000000..e1bd92a6 --- /dev/null +++ b/imports/plugins/included/default-theme/client/styles/toolbar.less @@ -0,0 +1,32 @@ +.rui.toolbar { + // Use bootstrap styles + .navbar(); + .navbar-inverse(); + display: flex; + align-items: center; + padding-left: @navbar-padding-horizontal; + padding-right: @navbar-padding-horizontal; +} + +.rui.toolbar-group { + display: flex; + flex: 0 0 auto; + align-items: center; + padding-left: @navbar-padding-horizontal; + padding-right: @navbar-padding-horizontal; +} + +.rui.toolbar-group.left { + flex: 0 0 auto; + justify-content: flex-start; +} + +.rui.toolbar-group.center { + flex: 0 0 auto; + justify-content: center; +} + +.rui.toolbar-group.right { + flex: 1 1 auto; + justify-content: flex-end; +} diff --git a/imports/plugins/included/default-theme/client/styles/tooltip.less b/imports/plugins/included/default-theme/client/styles/tooltip.less index 6cb536bf..604b46b8 100644 --- a/imports/plugins/included/default-theme/client/styles/tooltip.less +++ b/imports/plugins/included/default-theme/client/styles/tooltip.less @@ -10,7 +10,7 @@ .tooltip-element { position: absolute; - display: none; + display: none } .tooltip-element.tooltip-open { display: block; @@ -18,7 +18,7 @@ } .tooltip-element.tooltip-theme-arrows { - max-width: 100%; + max-width: 300px; max-height: 100%; } .tooltip-element.tooltip-theme-arrows .tooltip-content { diff --git a/imports/plugins/included/default-theme/client/styles/variables.less b/imports/plugins/included/default-theme/client/styles/variables.less index edcaf538..66c5da13 100644 --- a/imports/plugins/included/default-theme/client/styles/variables.less +++ b/imports/plugins/included/default-theme/client/styles/variables.less @@ -126,6 +126,14 @@ @btn-edit-bg: @black20; @btn-active-color: darken(@brand-accent-color, 50%); @btn-active-bg: @brand-accent-color; +@btn-icon-size: 24px; + +// == Badge +@badge-offset: @btn-icon-size / 2; +@badge-offset-top: -@badge-offset / 2; +@badge-offset-right: -@badge-offset / 2; +@badge-offset-bottom: @badge-offset / 2; +@badge-offset-left: @badge-offset / 2; //== Footer @footer-default-bg: transparent; diff --git a/imports/plugins/included/example-paymentmethod/client/checkout/example.js b/imports/plugins/included/example-paymentmethod/client/checkout/example.js index e9a105f7..19a980a8 100644 --- a/imports/plugins/included/example-paymentmethod/client/checkout/example.js +++ b/imports/plugins/included/example-paymentmethod/client/checkout/example.js @@ -8,6 +8,8 @@ import { ExamplePayment } from "../../lib/collections/schemas"; import "./example.html"; +let submitting = false; + function uiEnd(template, buttonText) { template.$(":input").removeAttr("disabled"); template.$("#btn-complete-order").text(buttonText); @@ -64,6 +66,7 @@ AutoForm.addHooks("example-payment-form", { uiEnd(template, "Resubmit payment"); } else { if (transaction.saved === true) { + submitting = false; paymentMethod = { processor: "Example", storedCard: storedCard, diff --git a/imports/plugins/included/inventory/server/methods/inventory.app-test.js b/imports/plugins/included/inventory/server/methods/inventory.app-test.js index 0b94a631..df3ca6e9 100644 --- a/imports/plugins/included/inventory/server/methods/inventory.app-test.js +++ b/imports/plugins/included/inventory/server/methods/inventory.app-test.js @@ -7,6 +7,7 @@ import { expect } from "meteor/practicalmeteor:chai"; import { sinon } from "meteor/practicalmeteor:sinon"; import { addProduct } from "/server/imports/fixtures/products"; import Fixtures from "/server/imports/fixtures"; +import { RevisionApi } from "/imports/plugins/core/revisions/lib/api/revisions"; Fixtures(); @@ -39,6 +40,7 @@ describe("inventory method", function () { beforeEach(function () { sandbox = sinon.sandbox.create(); + sandbox.stub(RevisionApi, "isRevisionControlEnabled", () => true); // again hack. w/o this we can't remove products from previous spec. Inventory.remove({}); // Empty Inventory }); @@ -62,21 +64,33 @@ describe("inventory method", function () { }); describe("inventory/remove", function () { - it("should remove deleted variants from inventory", function () { - // register inventory (that we'll should delete on variant removal) + // register inventory (that we'll should delete on variant removal) + let qty; + let newQty; + + before(function () { + qty = options[1].inventoryQuantity; + }); + + beforeEach(function () { sandbox.stub(Reaction, "hasPermission", () => true); + }); + + it("should have option quantity greater then 0", function () { // checking our option quantity. It should be greater than zero. - const qty = options[1].inventoryQuantity; expect(qty).to.be.above(0); - // before spec we're cleared collection, so we need to insert all docs - // again and make sure quantity will be equal with `qty` + }); + + it("should have equal quantities", function () { Meteor.call("inventory/register", options[1]); const midQty = Inventory.find({ variantId: options[1]._id }).count(); expect(midQty).to.equal(qty); + }); + + it("should have new quantity equal to 0", function () { // then we are removing option and docs should be automatically removed Meteor.call("products/deleteVariant", options[1]._id); - const newQty = Inventory.find({ variantId: options[1]._id }).count(); - expect(newQty).to.not.equal(qty); + newQty = Inventory.find({ variantId: options[1]._id }).count(); expect(newQty).to.equal(0); }); }); @@ -175,4 +189,3 @@ describe("inventory method", function () { // }); }); }); - diff --git a/imports/plugins/included/product-admin/client/components/index.js b/imports/plugins/included/product-admin/client/components/index.js new file mode 100644 index 00000000..12d90072 --- /dev/null +++ b/imports/plugins/included/product-admin/client/components/index.js @@ -0,0 +1 @@ +export { default as ProductAdmin } from "./productAdmin.js"; diff --git a/imports/plugins/included/product-admin/client/components/productAdmin.js b/imports/plugins/included/product-admin/client/components/productAdmin.js new file mode 100644 index 00000000..e5b71f0a --- /dev/null +++ b/imports/plugins/included/product-admin/client/components/productAdmin.js @@ -0,0 +1,258 @@ +import React, { Component, PropTypes } from "react"; +import { + Button, + Card, + CardHeader, + CardBody, + CardGroup, + Divider, + Metadata, + TextField, + Translation +} from "/imports/plugins/core/ui/client/components"; +import { Router } from "/client/api"; +import { PublishContainer } from "/imports/plugins/core/revisions"; +import { TagListContainer } from "/imports/plugins/core/ui/client/containers"; + +class ProductAdmin extends Component { + handleDeleteProduct = () => { + if (this.props.onDeleteProduct) { + this.props.onDeleteProduct(this.props.product); + } + } + + handleRestoreProduct = () => { + if (this.props.onRestoreProduct) { + this.props.onRestoreProduct(this.props.product); + } + } + + + handleFieldChange = (event, value, field) => { + if (this.props.onFieldChange) { + this.props.onFieldChange(field, value); + } + } + + handleToggleVisibility = () => { + if (this.props.onProductFieldSave) { + this.props.onProductFieldSave(this.product._id, "isVisible", !this.product.isVisible); + } + } + + handleMetaChange = (event, metafield, index) => { + if (this.props.onMetaChange) { + this.props.onMetaChange(metafield, index); + } + } + + handleFieldBlur = (event, value, field) => { + if (this.props.onProductFieldSave) { + this.props.onProductFieldSave(this.product._id, field, value); + } + } + + handleMetaSave = (event, metafield, index) => { + if (this.props.onMetaSave) { + this.props.onMetaSave(this.product._id, metafield, index); + } + } + + handleMetaRemove = (event, metafield, index) => { + if (this.props.onMetaRemove) { + this.props.onMetaRemove(this.product._id, metafield, index); + } + } + + get product() { + return this.props.product || {}; + } + + get permalink() { + if (this.props.product) { + return Router.pathFor("product", { + hash: { + handle: this.props.product.handle + } + }); + } + + return ""; + } + + renderProductVisibilityLabel() { + if (this.product.isVisible) { + return ( + + ); + } + + return ( + + ); + } + + render() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } +} + +ProductAdmin.propTypes = { + handleFieldBlur: PropTypes.func, + handleFieldChange: PropTypes.func, + handleProductFieldChange: PropTypes.func, + newMetafield: PropTypes.object, + onDeleteProduct: PropTypes.func, + onFieldChange: PropTypes.func, + onMetaChange: PropTypes.func, + onMetaRemove: PropTypes.func, + onMetaSave: PropTypes.func, + onProductFieldSave: PropTypes.func, + onRestoreProduct: PropTypes.func, + product: PropTypes.object, + revisonDocumentIds: PropTypes.arrayOf(PropTypes.string) +}; + +export default ProductAdmin; diff --git a/imports/plugins/included/product-admin/client/containers/index.js b/imports/plugins/included/product-admin/client/containers/index.js new file mode 100644 index 00000000..97bc93fc --- /dev/null +++ b/imports/plugins/included/product-admin/client/containers/index.js @@ -0,0 +1 @@ +export { default as ProductAdminContainer } from "./productAdminContainer"; diff --git a/imports/plugins/included/product-admin/client/containers/productAdminContainer.js b/imports/plugins/included/product-admin/client/containers/productAdminContainer.js new file mode 100644 index 00000000..684dcee5 --- /dev/null +++ b/imports/plugins/included/product-admin/client/containers/productAdminContainer.js @@ -0,0 +1,160 @@ +import React, { Component, PropTypes } from "react"; +import update from "react/lib/update"; +import { composeWithTracker } from "react-komposer"; +import { ReactionProduct } from "/lib/api"; +import { Tags, Media } from "/lib/collections"; +import { ProductAdmin } from "../components"; + +class ProductAdminContainer extends Component { + constructor(props) { + super(props); + + this.state = { + product: props.product, + newMetafield: { + key: "", + value: "" + } + }; + } + + componentWillReceiveProps(nextProps) { + this.setState({ + product: nextProps.product + }); + } + + get product() { + return this.state.product || this.props.product || {}; + } + + handleDeleteProduct = (product) => { + ReactionProduct.maybeDeleteProduct(product || this.product); + } + + handleFieldChange = (field, value) => { + const newState = update(this.state, { + product: { + $merge: { + [field]: value + } + } + }); + + this.setState(newState); + } + + handleProductFieldSave = (productId, fieldName, value) => { + Meteor.call("products/updateProductField", productId, fieldName, value); + } + + + handleMetaChange = (metafield, index) => { + let newState = {}; + + if (index >= 0) { + newState = update(this.state, { + product: { + metafields: { + [index]: { + $set: metafield + } + } + } + }); + } else { + newState = { + newMetafield: metafield + }; + } + + this.setState(newState); + } + + handleMetafieldSave = (productId, metafield, index) => { + // update existing metafield + if (index >= 0) { + Meteor.call("products/updateMetaFields", productId, metafield, index); + } else if (metafield.key && metafield.value) { + Meteor.call("products/updateMetaFields", productId, metafield); + } + + this.setState({ + newMetafield: { + key: "", + value: "" + } + }); + } + + handleMetaRemove = (productId, metafield) => { + Meteor.call("products/removeMetaFields", productId, metafield); + } + + handleProductRestore = (product) => { + Meteor.call("products/updateProductField", product._id, "isDeleted", false); + } + + render() { + return ( + + ); + } +} + + +function composer(props, onData) { + const product = ReactionProduct.selectedProduct(); + let tags; + let media; + let revisonDocumentIds; + + if (product) { + if (_.isArray(product.hashtags)) { + tags = _.map(product.hashtags, function (id) { + return Tags.findOne(id); + }); + } + + const selectedVariant = ReactionProduct.selectedVariant(); + + if (selectedVariant) { + media = Media.find({ + "metadata.variantId": selectedVariant._id + }, { + sort: { + "metadata.priority": 1 + } + }); + } + + revisonDocumentIds = [product._id]; + } + + onData(null, { + product: product, + media, + tags, + revisonDocumentIds + }); +} + +ProductAdminContainer.propTypes = { + product: PropTypes.object, + tags: PropTypes.arrayOf(PropTypes.object) +}; + +// Decorate component and export +export default composeWithTracker(composer)(ProductAdminContainer); diff --git a/imports/plugins/included/product-admin/client/index.js b/imports/plugins/included/product-admin/client/index.js new file mode 100644 index 00000000..80883aaa --- /dev/null +++ b/imports/plugins/included/product-admin/client/index.js @@ -0,0 +1,2 @@ +import "./templates/productAdmin.html"; +import "./templates/productAdmin.js"; diff --git a/imports/plugins/included/product-admin/client/templates/productAdmin.html b/imports/plugins/included/product-admin/client/templates/productAdmin.html new file mode 100644 index 00000000..5abd65ea --- /dev/null +++ b/imports/plugins/included/product-admin/client/templates/productAdmin.html @@ -0,0 +1,5 @@ + diff --git a/imports/plugins/included/product-admin/client/templates/productAdmin.js b/imports/plugins/included/product-admin/client/templates/productAdmin.js new file mode 100644 index 00000000..6a8e48db --- /dev/null +++ b/imports/plugins/included/product-admin/client/templates/productAdmin.js @@ -0,0 +1,16 @@ +import { ProductAdminContainer } from "../containers"; + +Template.ProductAdmin.helpers({ + component() { + const currentData = Template.currentData(); + let data; + + if (currentData && currentData.data) { + data = currentData.data; + } + + return Object.assign({}, data, { + component: ProductAdminContainer + }); + } +}); diff --git a/imports/plugins/included/product-admin/register.js b/imports/plugins/included/product-admin/register.js new file mode 100644 index 00000000..e69de29b diff --git a/imports/plugins/included/product-detail-simple/client/components/addToCartButton.js b/imports/plugins/included/product-detail-simple/client/components/addToCartButton.js new file mode 100644 index 00000000..5396095e --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/addToCartButton.js @@ -0,0 +1,42 @@ +import React, { Component, PropTypes } from "react"; +import { Translation } from "/imports/plugins/core/ui/client/components"; + + +class AddToCartButton extends Component { + hanleCartQuantityChange = (event) => { + if (this.props.onCartQuantityChange) { + this.props.onCartQuantityChange(event, event.target.value); + } + } + + render() { + return ( +
    + + +
    + ); + } +} + +AddToCartButton.propTypes = { + cartQuantity: PropTypes.number, + onCartQuantityChange: PropTypes.func, + onClick: PropTypes.func +}; + +export default AddToCartButton; diff --git a/imports/plugins/included/product-detail-simple/client/components/childVariant.js b/imports/plugins/included/product-detail-simple/client/components/childVariant.js new file mode 100644 index 00000000..7f53943d --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/childVariant.js @@ -0,0 +1,89 @@ +import React, { Component, PropTypes} from "react"; +import classnames from "classnames"; +import { Translation } from "/imports/plugins/core/ui/client/components"; +import { MediaItem } from "/imports/plugins/core/ui/client/components"; + +class ChildVariant extends Component { + handleClick = (event) => { + if (this.props.onClick) { + this.props.onClick(event, this.props.variant); + } + } + + get hasMedia() { + return Array.isArray(this.props.media) && this.props.media.length > 0; + } + + get primaryMediaItem() { + if (this.hasMedia) { + return this.props.media[0]; + } + + return null; + } + + renderDeletionStatus() { + if (this.props.variant.isDeleted) { + return ( + + + + ); + } + + return null; + } + + renderMedia() { + if (this.hasMedia) { + const media = this.primaryMediaItem; + + return ( + + ); + } + + return null; + } + + render() { + const variant = this.props.variant; + const classes = classnames({ + "btn": true, + "btn-default": true, + "variant-detail-selected": this.props.isSelected, + "variant-deleted": this.props.variant.isDeleted + }); + + return ( +
    + + +
    + {this.renderDeletionStatus()} + {this.props.visibilityButton} + {this.props.editButton} +
    +
    + ); + } +} + +ChildVariant.propTypes = { + editButton: PropTypes.node, + isSelected: PropTypes.bool, + media: PropTypes.arrayOf(PropTypes.object), + onClick: PropTypes.func, + variant: PropTypes.object, + visibilityButton: PropTypes.node +}; + + +export default ChildVariant; diff --git a/imports/plugins/included/product-detail-simple/client/components/index.js b/imports/plugins/included/product-detail-simple/client/components/index.js new file mode 100644 index 00000000..aadea499 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/index.js @@ -0,0 +1,7 @@ +export { default as ProductDetail } from "./productDetail"; +export { default as VariantList } from "./variantList"; +export { default as ChildVariant } from "./childVariant"; +export { default as AddToCartButton } from "./addToCartButton"; +export { default as ProductMetadata } from "./metadata"; +export { default as ProductTags } from "./tags"; +export { default as ProductField } from "./productField"; diff --git a/imports/plugins/included/product-detail-simple/client/components/metadata.js b/imports/plugins/included/product-detail-simple/client/components/metadata.js new file mode 100644 index 00000000..02d6d382 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/metadata.js @@ -0,0 +1,66 @@ +import React, { Component, PropTypes } from "react"; +import classnames from "classnames"; +import { Metadata, Translation } from "/imports/plugins/core/ui/client/components/"; +import { EditContainer } from "/imports/plugins/core/ui/client/containers"; + +class ProductMetadata extends Component { + get metafields() { + return this.props.metafields || this.props.product.metafields; + } + + get showEditControls() { + return this.props.product && this.props.editable; + } + + renderEditButton() { + if (this.showEditControls) { + return ( + + + + ); + } + + return null; + } + + render() { + if (Array.isArray(this.metafields) && this.metafields.length > 0) { + const headerClassName = classnames({ + "meta-header": true, + "edit": this.showEditControls + }); + + return ( +
    +

    + + {this.renderEditButton()} +

    + +
    + ); + } + + return null; + } +} + +ProductMetadata.propTypes = { + editContainerProps: PropTypes.object, + editable: PropTypes.bool, + metafields: PropTypes.arrayOf(PropTypes.object), + product: PropTypes.object +}; + +export default ProductMetadata; diff --git a/imports/plugins/included/product-detail-simple/client/components/productDetail.js b/imports/plugins/included/product-detail-simple/client/components/productDetail.js new file mode 100644 index 00000000..8dbb6dd7 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/productDetail.js @@ -0,0 +1,205 @@ +import React, { Component, PropTypes } from "react"; +import { + Button, + Currency, + DropDownMenu, + MenuItem, + Translation, + Toolbar, + ToolbarGroup +} from "/imports/plugins/core/ui/client/components/"; +import { + AddToCartButton, + ProductMetadata, + ProductTags, + ProductField +} from "./"; +import { AlertContainer } from "/imports/plugins/core/ui/client/containers"; +import { PublishContainer } from "/imports/plugins/core/revisions"; + +class ProductDetail extends Component { + get tags() { + return this.props.tags || []; + } + + get product() { + return this.props.product || {}; + } + + get editable() { + return this.props.editable; + } + + handleVisibilityChange = (event, isProductVisible) => { + if (this.props.onProductFieldChange) { + this.props.onProductFieldChange(this.product._id, "isVisible", isProductVisible); + } + } + + handlePublishActions = (event, action) => { + if (action === "delete" && this.props.onDeleteProduct) { + this.props.onDeleteProduct(this.product._id); + } + } + + renderToolbar() { + if (this.props.hasAdminPermission) { + return ( + + + + + + } + onChange={this.props.onViewContextChange} + value={this.props.viewAs} + > + + + + + + + + + ); + } + + return null; + } + + render() { + return ( +
    + {this.renderToolbar()} + +
    + + +
    + } + onProductFieldChange={this.props.onProductFieldChange} + product={this.product} + textFieldProps={{ + i18nKeyPlaceholder: "productDetailEdit.title", + placeholder: "Title" + }} + /> + + } + onProductFieldChange={this.props.onProductFieldChange} + product={this.product} + textFieldProps={{ + i18nKeyPlaceholder: "productDetailEdit.pageTitle", + placeholder: "Subtitle" + }} + /> +
    + + +
    +
    + {this.props.mediaGalleryComponent} + + +
    + +
    + + +
    +
    + + + + + +
    +
    + {this.props.socialComponent} +
    +
    + + +
    + +
    + +
    + +
    + +
    + {this.props.topVariantComponent} +
    +
    +
    + + +
    +
    +
    +
    +
    + ); + } +} + +ProductDetail.propTypes = { + cartQuantity: PropTypes.number, + editable: PropTypes.bool, + hasAdminPermission: PropTypes.bool, + mediaGalleryComponent: PropTypes.node, + onAddToCart: PropTypes.func, + onCartQuantityChange: PropTypes.func, + onDeleteProduct: PropTypes.func, + onProductFieldChange: PropTypes.func, + onViewContextChange: PropTypes.func, + priceRange: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + product: PropTypes.object, + socialComponent: PropTypes.node, + tags: PropTypes.arrayOf(PropTypes.object), + topVariantComponent: PropTypes.node, + viewAs: PropTypes.string +}; + +export default ProductDetail; diff --git a/imports/plugins/included/product-detail-simple/client/components/productField.js b/imports/plugins/included/product-detail-simple/client/components/productField.js new file mode 100644 index 00000000..bf9c9ad6 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/productField.js @@ -0,0 +1,132 @@ +import React, { Component, PropTypes } from "react"; +import classnames from "classnames"; +import { TextField } from "/imports/plugins/core/ui/client/components/"; +import { EditContainer } from "/imports/plugins/core/ui/client/containers"; + +class ProductField extends Component { + static state = {} + + constructor(props) { + super(props); + + this.state = { + value: this.value + }; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.product.pageTitle !== this.state.value) { + this.setState({ + value: nextProps.product[this.fieldName] + }); + } + } + + handleChange = (event, value) => { + this.setState({ + value + }); + } + + handleBlur = (event, value) => { + if (this.props.onProductFieldChange) { + this.props.onProductFieldChange(this.props.product._id, this.fieldName, value); + } + } + + get fieldName() { + return this.props.fieldName; + } + + get value() { + return (this.state && this.state.value) || this.props.product[this.fieldName]; + } + + get showEditControls() { + return this.props.product && this.props.editable; + } + + renderEditButton() { + if (this.showEditControls) { + return ( + + + + ); + } + + return null; + } + + renderTextField() { + const baseClassName = classnames({ + "pdp": true, + "product-detail-edit": true, + [`${this.fieldName}-edit`]: this.fieldName + }); + + const textFieldClassName = classnames({ + "pdp": true, + "product-detail-edit": true, + [`${this.fieldName}-edit-input`]: this.fieldName + }); + + return ( +
    + + {this.renderEditButton()} +
    + ); + } + + render() { + if (this.showEditControls) { + return this.renderTextField(); + } + + if (this.props.element) { + return React.cloneElement(this.props.element, { + className: "pdp field", + itemProp: this.props.itemProp, + children: this.value + }); + } + + return ( +
    + {this.value} +
    + ); + } +} + +ProductField.propTypes = { + editContainerProps: PropTypes.object, + editable: PropTypes.bool, + element: PropTypes.node, + fieldName: PropTypes.string, + fieldTitle: PropTypes.string, + itemProp: PropTypes.string, + multiline: PropTypes.bool, + onProductFieldChange: PropTypes.func, + product: PropTypes.object, + textFieldProps: PropTypes.object +}; + +export default ProductField; diff --git a/imports/plugins/included/product-detail-simple/client/components/tags.js b/imports/plugins/included/product-detail-simple/client/components/tags.js new file mode 100644 index 00000000..1c236b79 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/tags.js @@ -0,0 +1,67 @@ +import React, { Component, PropTypes } from "react"; +import classnames from "classnames"; +import { Translation, TagList } from "/imports/plugins/core/ui/client/components/"; +import { TagListContainer, EditContainer } from "/imports/plugins/core/ui/client/containers"; + +class ProductTags extends Component { + get tags() { + return this.props.tags; + } + + get showEditControls() { + return this.props.product && this.props.editable; + } + + renderEditButton() { + if (this.showEditControls) { + return ( + + + + ); + } + + return null; + } + + render() { + if (Array.isArray(this.tags) && this.tags.length > 0) { + const headerClassName = classnames({ + "tags-header": true, + "edit": this.showEditControls + }); + + return ( +
    +

    + + {this.renderEditButton()} +

    + +
    + ); + } + return null; + } +} + +ProductTags.propTypes = { + editButton: PropTypes.node, + editable: PropTypes.bool, + product: PropTypes.object, + tags: PropTypes.arrayOf(PropTypes.object) +}; + +export default ProductTags; diff --git a/imports/plugins/included/product-detail-simple/client/components/variant.js b/imports/plugins/included/product-detail-simple/client/components/variant.js new file mode 100644 index 00000000..b977a84a --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/variant.js @@ -0,0 +1,116 @@ +import React, { Component, PropTypes} from "react"; +import classnames from "classnames"; +import { Currency, Translation } from "/imports/plugins/core/ui/client/components"; +import { SortableItem } from "/imports/plugins/core/ui/client/containers"; + +class Variant extends Component { + + handleClick = (event) => { + if (this.props.onClick) { + this.props.onClick(event, this.props.variant); + } + } + + get price() { + return this.props.displayPrice || this.props.variant.price; + } + + renderInventoryStatus() { + const { + inventoryManagement, + inventoryPolicy + } = this.props.variant; + + if (inventoryManagement && this.props.soldOut) { + if (inventoryPolicy) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return null; + } + + renderDeletionStatus() { + if (this.props.variant.isDeleted) { + return ( + + + + ); + } + + return null; + } + + render() { + const variant = this.props.variant; + const classes = classnames({ + "variant-detail": true, + "variant-detail-selected": this.props.isSelected, + "variant-deleted": this.props.variant.isDeleted + }); + + const variantElement = ( +
  • +
    +
    + {variant.title} +
    + +
    + + + +
    + +
    + {this.renderDeletionStatus()} + {this.renderInventoryStatus()} + {this.props.visibilityButton} + {this.props.editButton} +
    +
    +
  • + ); + + if (this.props.editable) { + return this.props.connectDragSource( + this.props.connectDropTarget( + variantElement + ) + ); + } + + return variantElement; + } +} + +Variant.propTypes = { + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + displayPrice: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + editButton: PropTypes.node, + editable: PropTypes.bool, + isSelected: PropTypes.bool, + onClick: PropTypes.func, + soldOut: PropTypes.bool, + variant: PropTypes.object, + visibilityButton: PropTypes.node +}; + +export default SortableItem("product-variant", Variant); diff --git a/imports/plugins/included/product-detail-simple/client/components/variantList.js b/imports/plugins/included/product-detail-simple/client/components/variantList.js new file mode 100644 index 00000000..b3cd1cdb --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/components/variantList.js @@ -0,0 +1,159 @@ +import React, { Component, PropTypes} from "react"; +import Variant from "./variant"; +import { EditContainer } from "/imports/plugins/core/ui/client/containers"; +import { Divider, Translation } from "/imports/plugins/core/ui/client/components"; +import { ChildVariant } from "./"; + +class VariantList extends Component { + + handleVariantEditClick = (event, editButtonProps) => { + if (this.props.onEditVariant) { + return this.props.onEditVariant(event, editButtonProps.data); + } + return true; + } + + handleVariantVisibilityClick = (event, editButtonProps) => { + if (this.props.onVariantVisibiltyToggle) { + const isVariantVisible = !editButtonProps.data.isVisible; + this.props.onVariantVisibiltyToggle(event, editButtonProps.data, isVariantVisible); + } + } + + handleChildleVariantClick = (event, variant) => { + if (this.props.onVariantClick) { + this.props.onVariantClick(event, variant, 1); + } + } + + handleChildVariantEditClick = (event, editButtonProps) => { + if (this.props.onEditVariant) { + return this.props.onEditVariant(event, editButtonProps.data, 1); + } + return true; + } + + isSoldOut(variant) { + if (this.props.isSoldOut) { + return this.props.isSoldOut(variant); + } + + return false; + } + + renderVariants() { + if (this.props.variants) { + return this.props.variants.map((variant, index) => { + const displayPrice = this.props.displayPrice && this.props.displayPrice(variant._id); + + return ( + + + + ); + }); + } + + return ( +
  • + + {"+"} + +
  • + ); + } + + renderChildVariants() { + if (this.props.childVariants) { + return this.props.childVariants.map((childVariant, index) => { + const media = this.props.childVariantMedia.filter((mediaItem) => { + if (mediaItem.metadata.variantId === childVariant._id) { + return true; + } + return false; + }); + + return ( + + + + ); + }); + } + + return null; + } + + render() { + return ( +
    + +
      + {this.renderVariants()} +
    + +
    + {this.renderChildVariants()} +
    +
    + ); + } +} + +VariantList.propTypes = { + childVariantMedia: PropTypes.arrayOf(PropTypes.any), + childVariants: PropTypes.arrayOf(PropTypes.object), + displayPrice: PropTypes.func, + editable: PropTypes.bool, + isSoldOut: PropTypes.func, + onEditVariant: PropTypes.func, + onMoveVariant: PropTypes.func, + onVariantClick: PropTypes.func, + onVariantVisibiltyToggle: PropTypes.func, + variantIsSelected: PropTypes.func, + variants: PropTypes.arrayOf(PropTypes.object) +}; + +export default VariantList; diff --git a/imports/plugins/included/product-detail-simple/client/containers/index.js b/imports/plugins/included/product-detail-simple/client/containers/index.js new file mode 100644 index 00000000..850ec7cb --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/containers/index.js @@ -0,0 +1,3 @@ +export { default as ProductDetailContainer } from "./productDetailContainer"; +export { default as SocialContainer } from "./socialContainer"; +export { default as VariantListContainer } from "./variantListContainer"; diff --git a/imports/plugins/included/product-detail-simple/client/containers/productDetailContainer.js b/imports/plugins/included/product-detail-simple/client/containers/productDetailContainer.js new file mode 100644 index 00000000..ac3534b5 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/containers/productDetailContainer.js @@ -0,0 +1,261 @@ +import React, { Component, PropTypes } from "react"; +import { composeWithTracker } from "react-komposer"; +import { Meteor } from "meteor/meteor"; +import { ReactionProduct } from "/lib/api"; +import { Reaction, i18next, Logger } from "/client/api"; +import { Tags, Media } from "/lib/collections"; +import { Loading } from "/imports/plugins/core/ui/client/components"; +import { ProductDetail } from "../components"; +import { SocialContainer, VariantListContainer } from "./"; +import { MediaGalleryContainer } from "/imports/plugins/core/ui/client/containers"; +import { DragDropProvider, TranslationProvider } from "/imports/plugins/core/ui/client/providers"; + +class ProductDetailContainer extends Component { + constructor(props) { + super(props); + + this.state = { + cartQuantity: 1 + }; + } + + handleCartQuantityChange = (event, quantity) => { + this.setState({ + cartQuantity: Math.max(quantity, 1) + }); + } + + handleAddToCart = () => { + let productId; + let quantity; + const currentVariant = ReactionProduct.selectedVariant(); + const currentProduct = ReactionProduct.selectedProduct(); + + if (currentVariant) { + if (currentVariant.ancestors.length === 1) { + const options = ReactionProduct.getVariants(currentVariant._id); + + if (options.length > 0) { + Alerts.inline("Please choose options before adding to cart", "warning", { + placement: "productDetail", + i18nKey: "productDetail.chooseOptions", + autoHide: 10000 + }); + return []; + } + } + + if (currentVariant.inventoryPolicy && currentVariant.inventoryQuantity < 1) { + Alerts.inline("Sorry, this item is out of stock!", "warning", { + placement: "productDetail", + i18nKey: "productDetail.outOfStock", + autoHide: 10000 + }); + return []; + } + + quantity = parseInt(this.state.cartQuantity, 10); + + if (quantity < 1) { + quantity = 1; + } + + if (!currentProduct.isVisible) { + Alerts.inline("Publish product before adding to cart.", "error", { + placement: "productDetail", + i18nKey: "productDetail.publishFirst", + autoHide: 10000 + }); + } else { + productId = currentProduct._id; + + if (productId) { + Meteor.call("cart/addToCart", productId, currentVariant._id, quantity, (error) => { + if (error) { + Logger.error("Failed to add to cart.", error); + return error; + } + // Reset cart quantity on success + this.handleCartQuantityChange(null, 1); + + return true; + }); + } + + // template.$(".variant-select-option").removeClass("active"); + ReactionProduct.setCurrentVariant(null); + // qtyField.val(1); + // scroll to top on cart add + $("html,body").animate({ + scrollTop: 0 + }, 0); + // slide out label + const addToCartText = i18next.t("productDetail.addedToCart"); + const addToCartTitle = currentVariant.title || ""; + $(".cart-alert-text").text(`${quantity} ${addToCartTitle} ${addToCartText}`); + + // Grab and cache the width of the alert to be used in animation + const alertWidth = $(".cart-alert").width(); + const direction = i18next.t("languageDirection") === "rtl" ? "left" : "right"; + const oppositeDirection = i18next.t("languageDirection") === "rtl" ? "right" : "left"; + + // Animate + return $(".cart-alert") + .show() + .css({ + [oppositeDirection]: "auto", + [direction]: -alertWidth + }) + .animate({ + [oppositeDirection]: "auto", + [direction]: 0 + }, 600) + .delay(4000) + .animate({ + [oppositeDirection]: "auto", + [direction]: -alertWidth + }, { + duration: 600, + complete() { + $(".cart-alert").hide(); + } + }); + } + } else { + Alerts.inline("Select an option before adding to cart", "warning", { + placement: "productDetail", + i18nKey: "productDetail.selectOption", + autoHide: 8000 + }); + } + + return null; + } + + handleProductFieldChange = (productId, fieldName, value) => { + Meteor.call("products/updateProductField", productId, fieldName, value); + } + + handleViewContextChange = (event, value) => { + Reaction.Router.setQueryParams({as: value}); + } + + handleDeleteProduct = () => { + ReactionProduct.maybeDeleteProduct(this.props.product); + } + + render() { + return ( + + + } + onAddToCart={this.handleAddToCart} + onCartQuantityChange={this.handleCartQuantityChange} + onViewContextChange={this.handleViewContextChange} + socialComponent={} + topVariantComponent={} + onDeleteProduct={this.handleDeleteProduct} + onProductFieldChange={this.handleProductFieldChange} + {...this.props} + /> + + + ); + } +} + +ProductDetailContainer.propTypes = { + media: PropTypes.arrayOf(PropTypes.object), + product: PropTypes.object +}; + +function composer(props, onData) { + const tagSub = Meteor.subscribe("Tags"); + const productId = Reaction.Router.getParam("handle"); + const variantId = Reaction.Router.getParam("variantId"); + const revisionType = Reaction.Router.getQueryParam("revision"); + const viewProductAs = Reaction.Router.getQueryParam("as"); + + let productSub; + + if (productId) { + productSub = Meteor.subscribe("Product", productId); + } + + if (productSub && productSub.ready() && tagSub.ready()) { + // Get the product + const product = ReactionProduct.setProduct(productId, variantId); + + if (Reaction.hasPermission("createProduct")) { + if (!Reaction.getActionView() && Reaction.isActionViewOpen() === true) { + Reaction.setActionView({ + template: "productAdmin", + data: product + }); + } + } + + // Get the product tags + if (product) { + let tags; + if (_.isArray(product.hashtags)) { + tags = _.map(product.hashtags, function (id) { + return Tags.findOne(id); + }); + } + + let mediaArray = []; + const selectedVariant = ReactionProduct.selectedVariant(); + + if (selectedVariant) { + mediaArray = Media.find({ + "metadata.variantId": selectedVariant._id + }, { + sort: { + "metadata.priority": 1 + } + }).fetch(); + } + + let priceRange; + if (selectedVariant && typeof selectedVariant === "object") { + const childVariants = ReactionProduct.getVariants(selectedVariant._id); + // when top variant has no child variants we display only its price + if (childVariants.length === 0) { + priceRange = selectedVariant.price; + } + // otherwise we want to show child variants price range + priceRange = ReactionProduct.getVariantPriceRange(); + } + + let productRevision; + + if (revisionType === "published") { + productRevision = product.__published; + } + + let editable; + + if (viewProductAs === "customer") { + editable = false; + } else { + editable = Reaction.hasPermission(["createProduct"]); + } + + onData(null, { + product: productRevision || product, + priceRange, + tags, + media: mediaArray, + editable, + viewAs: viewProductAs, + hasAdminPermission: Reaction.hasPermission(["createProduct"]) + }); + } + } +} + +// Decorate component and export +export default composeWithTracker(composer, Loading)(ProductDetailContainer); diff --git a/imports/plugins/included/product-detail-simple/client/containers/socialContainer.js b/imports/plugins/included/product-detail-simple/client/containers/socialContainer.js new file mode 100644 index 00000000..f5eddf82 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/containers/socialContainer.js @@ -0,0 +1,100 @@ +import React, { Component, PropTypes } from "react"; +import { composeWithTracker } from "react-komposer"; +import { 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() { + return ( + + + + ); + } +} + +function composer(props, onData) { + const product = ReactionProduct.selectedProduct(); + const selectedVariant = ReactionProduct.selectedVariant() || {}; + let title = product.title; + let mediaUrl; + + if (selectedVariant) { + title = selectedVariant.title; + } + + let description; + + if (typeof product.description === "string") { + description = product.description.substring(0, 254); + } + + const media = Media.findOne({ + "metadata.variantId": { + $in: [ + selectedVariant._id, + product._id + ] + } + }, { + sort: { + "metadata.priority": 1 + } + }); + + if (media) { + mediaUrl = media.url(); + } + + const options = { + data: product, + title: product.title, + description, + placement: "productDetail", + buttonClassName: "fa-lg", + media: mediaUrl, + apps: { + facebook: { + description: product.facebookMsg || description, + media: mediaUrl + }, + twitter: { + description: product.twitterMsg || title, + media: mediaUrl + }, + googleplus: { + itemtype: "Product", + description: product.googleplusMsg || description, + media: mediaUrl + }, + pinterest: { + description: product.pinterestMsg || description, + media: mediaUrl + } + } + }; + + const socialSettings = createSocialSettings(options); + + onData(null, { + data: product, + socialSettings + }); +} + +ProductSocialContainer.propTypes = { + data: PropTypes.object, + socialSettings: PropTypes.object +}; + +export default composeWithTracker(composer)(ProductSocialContainer); diff --git a/imports/plugins/included/product-detail-simple/client/containers/variantListContainer.js b/imports/plugins/included/product-detail-simple/client/containers/variantListContainer.js new file mode 100644 index 00000000..78ccc6ba --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/containers/variantListContainer.js @@ -0,0 +1,213 @@ +import React, { Component, PropTypes } from "react"; +import { composeWithTracker } from "react-komposer"; +import { ReactionProduct } from "/lib/api"; +import { Reaction } from "/client/api"; +import { VariantList } from "../components"; +import { getChildVariants } from "../selectors/variants"; +import { Products, Media } from "/lib/collections"; +import update from "react/lib/update"; +import { getVariantIds } from "/lib/selectors/variants"; +import { DragDropProvider } from "/imports/plugins/core/ui/client/providers"; + +function variantIsSelected(variantId) { + const current = ReactionProduct.selectedVariant(); + if (current && typeof current === "object" && (variantId === current._id || ~current.ancestors.indexOf(variantId))) { + 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; +} + +function getTopVariants() { + 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 []; +} + +function isSoldOut(variant) { + return ReactionProduct.getVariantQuantity(variant) < 1; +} + +class VariantListContainer extends Component { + componentWillReceiveProps() { + this.setState({}); + } + + get variants() { + return (this.state && this.state.variants) || this.props.variants; + } + + handleVariantClick = (event, variant, ancestors = -1) => { + if (Reaction.isActionViewOpen()) { + this.handleEditVariant(event, variant, ancestors); + } else { + 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 + }, { + as: Reaction.Router.getQueryParam("as") + }); + } + } + + handleEditVariant = (event, variant, ancestors = -1) => { + const selectedProduct = ReactionProduct.selectedProduct(); + let editVariant = variant; + if (ancestors >= 0) { + editVariant = Products.findOne(variant.ancestors[ancestors]); + } + + ReactionProduct.setCurrentVariant(variant._id); + Session.set("variant-form-" + editVariant._id, true); + Reaction.Router.go("product", { + handle: selectedProduct.handle, + variantId: variant._id + }, { + as: Reaction.Router.getQueryParam("as") + }); + + if (Reaction.hasPermission("createProduct")) { + Reaction.showActionView({ + label: "Edit Variant", + i18nKeyLabel: "productDetailEdit.editVariant", + template: "variantForm", + data: editVariant + }); + } + + // Prevent the default edit button `onEditButtonClick` function from running + return false; + } + + handleVariantVisibilityToggle = (event, variant, variantIsVisible) => { + Meteor.call("products/updateProductField", variant._id, "isVisible", variantIsVisible); + } + + handleMoveVariant = (dragIndex, hoverIndex) => { + const variant = this.props.variants[dragIndex]; + + // Apply new sort order to variant list + const newVariantOrder = update(this.props.variants, { + $splice: [ + [dragIndex, 1], + [hoverIndex, 0, variant] + ] + }); + + // 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({ + variants: newVariantOrder + }); + + // Save the updated positions + Meteor.defer(() => { + Meteor.call("products/updateVariantsPosition", getVariantIds(newVariantOrder)); + }); + } + + render() { + return ( + + + + ); + } +} + +function composer(props, onData) { + let childVariantMedia = []; + const childVariants = getChildVariants(); + + if (Array.isArray(childVariants)) { + childVariantMedia = Media.find({ + "metadata.variantId": { + $in: getVariantIds(childVariants) + } + }, { + sort: { + "metadata.priority": 1 + } + }).fetch(); + } + + let editable; + + if (Reaction.Router.getQueryParam("as") === "customer") { + editable = false; + } else { + editable = Reaction.hasPermission(["createProduct"]); + } + + onData(null, { + variants: getTopVariants(), + variantIsSelected, + variantIsInActionView, + childVariants, + childVariantMedia, + displayPrice: ReactionProduct.getVariantPriceRange, + isSoldOut: isSoldOut, + editable + }); +} + +VariantListContainer.propTypes = { + variants: PropTypes.arrayOf(PropTypes.object) +}; + +export default composeWithTracker(composer)(VariantListContainer); diff --git a/imports/plugins/included/product-detail-simple/client/index.js b/imports/plugins/included/product-detail-simple/client/index.js new file mode 100644 index 00000000..4f4e214b --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/index.js @@ -0,0 +1,2 @@ +import "./templates/productDetailSimple.html"; +import "./templates/productDetailSimple.js"; diff --git a/imports/plugins/included/product-detail-simple/client/selectors/variants.js b/imports/plugins/included/product-detail-simple/client/selectors/variants.js new file mode 100644 index 00000000..a7cb9a23 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/selectors/variants.js @@ -0,0 +1,39 @@ +import { ReactionProduct } from "/lib/api"; + +export function getChildVariants() { + 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); + } + }); + } 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 null; +} diff --git a/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.html b/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.html new file mode 100644 index 00000000..041fe1ea --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.html @@ -0,0 +1,11 @@ + diff --git a/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.js b/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.js new file mode 100644 index 00000000..d2b55b43 --- /dev/null +++ b/imports/plugins/included/product-detail-simple/client/templates/productDetailSimple.js @@ -0,0 +1,11 @@ +import { ProductDetailContainer } from "../containers"; +import { isRevisionControlEnabled } from "/imports/plugins/core/revisions/lib/api"; + +Template.productDetailSimple.helpers({ + isEnabled() { + return isRevisionControlEnabled(); + }, + PDC() { + return ProductDetailContainer; + } +}); diff --git a/imports/plugins/included/product-detail-simple/register.js b/imports/plugins/included/product-detail-simple/register.js new file mode 100644 index 00000000..da3bd3aa --- /dev/null +++ b/imports/plugins/included/product-detail-simple/register.js @@ -0,0 +1,37 @@ +import { Reaction } from "/server/api"; + +Reaction.registerPackage({ + label: "Product Detail Simple", + name: "product-detail-simple", + icon: "fa fa-cubes", + autoEnable: true, + registry: [{ + route: "/product/:handle/:variantId?", + name: "product", + template: "productDetailSimple", + workflow: "coreProductWorkflow" + }, { + label: "Product Details", + provides: "settings", + route: "/product/:handle/:variantId?", + container: "product", + template: "ProductAdmin" + }], + layout: [{ + layout: "coreLayout", + workflow: "coreProductWorkflow", + collection: "Products", + theme: "default", + enabled: true, + structure: { + template: "productDetailSimple", + layoutHeader: "layoutHeader", + layoutFooter: "", + notFound: "productNotFound", + dashboardHeader: "", + dashboardControls: "productDetailDashboardControls", + dashboardHeaderControls: "", + adminControlsFooter: "adminControlsFooter" + } + }] +}); 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 index 292daff1..7309ebaa 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.js +++ b/imports/plugins/included/product-variant/client/templates/products/productDetail/attributes.js @@ -34,14 +34,13 @@ Template.metaComponent.events({ "change input": function (event) { const productId = ReactionProduct.selectedProductId(); const updateMeta = { - key: $(event.currentTarget).parent().children( - ".metafield-key-input").val(), - value: $(event.currentTarget).parent().children( - ".metafield-value-input").val() + key: $(event.currentTarget).parent().children(".metafield-key-input").val(), + value: $(event.currentTarget).parent().children(".metafield-value-input").val() }; + if (this.key) { - Meteor.call("products/updateMetaFields", productId, updateMeta, - this); + const index = $(event.currentTarget).closest(".metafield-list-item").index(); + Meteor.call("products/updateMetaFields", productId, updateMeta, index); $(event.currentTarget).animate({ backgroundColor: "#e2f2e2" }).animate({ @@ -51,16 +50,13 @@ Template.metaComponent.events({ } if (updateMeta.value && !updateMeta.key) { - $(event.currentTarget).parent().children(".metafield-key-input").val( - "").focus(); + $(event.currentTarget).parent().children(".metafield-key-input").val("").focus(); } if (updateMeta.key && updateMeta.value) { Meteor.call("products/updateMetaFields", productId, updateMeta); Tracker.flush(); - $(event.currentTarget).parent().children(".metafield-key-input").val( - "").focus(); - return $(event.currentTarget).parent().children( - ".metafield-value-input").val(""); + $(event.currentTarget).parent().children(".metafield-key-input").val("").focus(); + return $(event.currentTarget).parent().children(".metafield-value-input").val(""); } } }); diff --git a/imports/plugins/included/product-variant/client/templates/products/productDetail/productDetail.html b/imports/plugins/included/product-variant/client/templates/products/productDetail/productDetail.html index 02375616..d01d53bb 100644 --- a/imports/plugins/included/product-variant/client/templates/products/productDetail/productDetail.html +++ b/imports/plugins/included/product-variant/client/templates/products/productDetail/productDetail.html @@ -1,6 +1,5 @@