diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000000..4598eded4d4 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,165 @@ +version: 2 # use CircleCI 2.0 + +defaults: &defaults + environment: &environment + CIRCLE_TEST_REPORTS: /tmp/test-results + CIRCLE_ARTIFACTS: /tmp/test-artifacts + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + BUNDLE_PATH: ~/spree/vendor/bundle + working_directory: ~/spree + docker: + - image: &image circleci/ruby:2.5-node-browsers + +run_tests: &run_tests + <<: *defaults + parallelism: 4 + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-v3-{{ checksum ".ruby-version" }}-{{ .Branch }} + - spree-bundle-v3-{{ checksum ".ruby-version" }} + - run: + name: Ensure Bundle Install + command: | + bundle check --path=~/spree/vendor/bundle || bundle install + ./build-ci.rb install + - run: + name: Run rspec in parallel + command: BUNDLE_GEMFILE=../Gemfile ./build-ci.rb test + - store_artifacts: + path: /tmp/test-artifacts + destination: test-artifacts + - store_artifacts: + path: /tmp/test-results + destination: raw-test-output + - store_test_results: + path: /tmp/test-results + +jobs: + bundle_install: + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-v3-{{ checksum ".ruby-version" }}-{{ .Branch }} + - spree-bundle-v3-{{ checksum ".ruby-version" }} + - run: + name: Bundle Install + command: | + bundle check --path=~/spree/vendor/bundle || bundle install + ./build-ci.rb install + - save_cache: + key: spree-bundle-v3-{{ checksum ".ruby-version" }}-{{ .Branch }} + paths: + - ~/spree/vendor/bundle + + run_tests_postgres: &run_tests_postgres + <<: *run_tests + environment: &postgres_environment + <<: *environment + DB: postgres + DB_HOST: localhost + DB_USERNAME: postgres + docker: + - image: *image + - image: circleci/postgres:10-alpine + environment: + POSTGRES_USER: postgres + + run_tests_postgres_paperclip: + <<: *run_tests_postgres + environment: + <<: *postgres_environment + SPREE_USE_PAPERCLIP: true + + run_tests_mysql: + <<: *run_tests + environment: + <<: *environment + DB: mysql + DB_HOST: 127.0.0.1 + DB_USERNAME: root + COVERAGE: true + COVERAGE_DIR: /tmp/workspace/simplecov + docker: + - image: *image + - image: circleci/mysql:8-ram + command: [--default-authentication-plugin=mysql_native_password] + steps: + - checkout + - restore_cache: + keys: + - spree-bundle-v3-{{ checksum ".ruby-version" }}-{{ .Branch }} + - spree-bundle-v3-{{ checksum ".ruby-version" }} + - run: + name: Ensure Bundle Install + command: | + bundle check --path=~/spree/vendor/bundle || bundle install + ./build-ci.rb install + - run: + name: Run rspec in parallel + command: BUNDLE_GEMFILE=../Gemfile ./build-ci.rb test + - store_artifacts: + path: /tmp/test-artifacts + destination: test-artifacts + - store_artifacts: + path: /tmp/test-results + destination: raw-test-output + - store_test_results: + path: /tmp/test-results + - persist_to_workspace: + root: /tmp/workspace + paths: + - simplecov + + send_test_coverage: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: /tmp/workspace + - run: + name: Download cc-test-reporter + command: | + mkdir -p tmp/ + curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./tmp/cc-test-reporter + chmod +x ./tmp/cc-test-reporter + - run: + name: Setup ENVs + command: | + export GIT_BRANCH="$CIRCLE_BRANCH" + export GIT_COMMIT_SHA="$CIRCLE_SHA1" + export GIT_COMMITTED_AT="$(date +%s)" + - run: + name: Format test coverage + command: | + ./tmp/cc-test-reporter format-coverage -t simplecov -o tmp/codeclimate.api.json /tmp/workspace/simplecov/api/.resultset.json + ./tmp/cc-test-reporter format-coverage -t simplecov -o tmp/codeclimate.backend.json /tmp/workspace/simplecov/backend/.resultset.json + ./tmp/cc-test-reporter format-coverage -t simplecov -o tmp/codeclimate.core.json /tmp/workspace/simplecov/core/.resultset.json + ./tmp/cc-test-reporter format-coverage -t simplecov -o tmp/codeclimate.frontend.json /tmp/workspace/simplecov/frontend/.resultset.json + - run: + name: Upload coverage results to Code Climate + command: | + ./tmp/cc-test-reporter sum-coverage tmp/codeclimate.*.json -p 4 -o tmp/codeclimate.total.json + ./tmp/cc-test-reporter upload-coverage -i tmp/codeclimate.total.json + +workflows: + version: 2 + main: + jobs: + - bundle_install + - run_tests_postgres: + requires: + - bundle_install + - run_tests_mysql: + requires: + - bundle_install + - run_tests_postgres_paperclip: + requires: + - run_tests_postgres + - send_test_coverage: + requires: + - run_tests_mysql diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000000..9674ec51155 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,20 @@ +version: "2" +plugins: + rubocop: + enabled: true + config: + file: .rubocop.yml + channel: "rubocop-0-60" # need to keep this value the same as rubocop version + # https://docs.codeclimate.com/v1.0/docs/rubocop#section-using-rubocop-s-newer-versions + eslint: + enabled: true + channel: "eslint-4" # need to keep this value the same as eslint version + # https://docs.codeclimate.com/v1.0/docs/eslint#section-eslint-versions + stylelint: + enabled: true +exclude_patterns: + - "**/bin/" + - "**/script/" + - "**/vendor/" + - "**/spec/" + - "public/" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..3f34120dc96 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# http://EditorConfig.org +# https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +tab_width = 2 + +[**.rb] +max_line_length = 150 + +[**.js, **.coffee] +max_line_length = 150 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..a6eabd061b4 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +**/vendor/** +**/dummy/** +**/sandbox/** +**/guides/output/** +*-min.js +*.min.js +jquery*.js +/guides/content/assets/javascripts/css_browser_selector_dev.js +/cmd/lib/spree_cmd/templates/** diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000000..f5c1086eb2d --- /dev/null +++ b/.eslintrc @@ -0,0 +1,17 @@ +--- +extends: standard + +globals: + Spree: true + analytics: true + ga: true + _: true + Select2: true + Handlebars: true + Raphael: true + SpreeAPI: true + SpreePaths: true + +env: + browser: true + jquery: true diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000000..f4409735330 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,63 @@ +## Pull requests + +We gladly accept pull requests to add documentation, fix bugs and, in some circumstances, +add new features to Spree. + +Here's a quick guide: + +1. Fork the repo + +2. Clone the fork to your local machine + +3. Run `bundle install` inside `spree` directory + +4. Create a sandbox environment + + ``` + rake sandbox + ``` + +5. To run a sandbox application: + + ``` + cd sandbox + rails s + ``` + +6. Create new branch then make changes and add tests for your changes. Only +refactoring and documentation changes require no new tests. If you are adding +functionality or fixing a bug, we need tests! + +7. Run the tests. [See instructions](https://github.com/spree/spree#running-tests) + +8. Push to your fork and submit a pull request. If the changes will apply cleanly +to the master branch, you will only need to submit one pull request. + + Don't do pull requests against `-stable` branches. Always target the master branch. Any bugfixes we'll backport to those branches. + +At this point, you're waiting on us. We like to at least comment on, if not +accept, pull requests within three business days (and, typically, one business +day). We may suggest some changes or improvements or alternatives. + +Some things that will increase the chance that your pull request is accepted, +taken straight from the Ruby on Rails guide: + +* Use Rails idioms and helpers +* Include tests that fail without your code, and pass with it +* Update the documentation, the surrounding one, examples elsewhere, guides, + whatever is affected by your contribution + +Syntax: + +* Two spaces, no tabs. +* No trailing whitespace. Blank lines should not have any space. +* Use &&/|| over and/or. +* `MyClass.my_method(my_arg)` not `my_method( my_arg )` or `my_method my_arg`. +* `a = b` and not `a=b`. +* `a_method { |block| ... }` and not `a_method { | block | ... }` +* Follow the conventions you see used in the source already. +* -> symbol over lambda +* Ruby 1.9 hash syntax `{ key: value }` over Ruby 1.8 hash syntax `{ :key => value }` +* Alphabetize the class methods to keep them organized + +And in case we didn't emphasize it enough: we love tests! diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000000..94df01c52ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,39 @@ + + + +## Context + + + +## Expected Behavior + + +## Actual Behavior + + +## Possible Fix + + +## Steps to Reproduce + + +1. +2. +3. +4. + +## Your Environment + +* Version used: +* Gemfile and Gemfile.lock as text in a Gist: +* Any relevant stack traces ("Full trace" preferred): + + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4b5b2f47255..711ed2839e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,70 @@ -starter-app/log/*.log -starter-app/public/images/products -starter-app/public/images/calendar_date_select -starter-app/public/plugin_assets -starter-app/public/javascripts/calendar_date_select -starter-app/public/stylesheets/calendar_date_select +\#* +**/*.db +**/*.log +**/*.byebug_history +*~ +.#* +.DS_Store +.bundle +.dotest +.idea +.loadpath +.project +.vscode +bin/* +public/dispatch.cgi +public/dispatch.fcgi +public/dispatch.rb +httpd +pgsql +config/*-public.asc +config/database.yml +config/mongrel_cluster.yml +db/*.sql +db/*.sqlite3* +db/schema.rb +doc/**/* +Gemfile.lock +*/Gemfile.lock +lib/products_index_profiler.rb +log/*.log pkg -starter-app/tmp +public/assets +public/attachments +public/blank_iframe.html +public/ckeditora +sandbox/* +spree_dev +spree_test +testapp +**/spec/dummy +tmp +public/google_base.xml +public/template_google_base.xml +coverage/* +var +TAGS +nbproject +./vendor +tags +*.swp +rerun.txt +test_app +.rvmrc +**/coverage +*/.sass-cache +.localeapp +.ruby-gemset +vendor/bundle +*/vendor/bundle +.ignore + +# new guides +guides/output +guides/logs +guides/.cache +guides/public +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.yarn-integrity diff --git a/.rubocop-disabled.yml b/.rubocop-disabled.yml new file mode 100644 index 00000000000..35edf7aeb8f --- /dev/null +++ b/.rubocop-disabled.yml @@ -0,0 +1,49 @@ +# disabled for rubocop-rspec + +RSpec/DescribeClass: + Enabled: false + +RSpec/VerifiedDoubles: + Enabled: false + +RSpec/MessageChain: + Enabled: false + +RSpec/AnyInstance: + Enabled: false + +RSpec/InstanceVariable: + Enabled: false + +RSpec/ContextWording: + Enabled: false + +RSpec/ExpectInHook: + Enabled: false + +RSpec/ExampleLength: + Enabled: false + +RSpec/MessageSpies: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/FilePath: + Enabled: false + +RSpec/LetSetup: + Enabled: false + +RSpec/SubjectStub: + Enabled: false + +RSpec/VoidExpect: + Enabled: false + +RSpec/BeforeAfterAll: + Enabled: false diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000000..ef4c956aaa0 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,137 @@ +require: rubocop-rspec + +inherit_from: '.rubocop-disabled.yml' + +AllCops: + TargetRubyVersion: 2.3 + Exclude: + - '**/sandbox/**/*' + - '**/db/migrate/*' + - '**/Gemfile' + - '**/Gemfile.lock' + - '**/Rakefile' + - '**/rails' + - 'guides/**/*' + - '**/*.gemspec' + - '**/dummy/**/*' + - '**/vendor/**/*' + - '**/spec_helper.rb' + - '**/templates/**/*' + +Layout/MultilineOperationIndentation: + EnforcedStyle: indented + +AlignParameters: + Enabled: false + +ClassLength: + CountComments: false + Max: 150 + +ModuleLength: + CountComments: false + Max: 250 + Exclude: + - '**/spec/**/*' + +Documentation: + Enabled: false + +Metrics/LineLength: + Max: 150 + Exclude: + - '**/spec/**/*' + +MethodLength: + CountComments: false + Max: 50 + +BlockLength: + CountComments: false + Max: 50 + Exclude: + - '**/spec/**/*' + - '**/*.rake' + - '**/factories/**/*' + - '**/config/routes.rb' + +Metrics/AbcSize: + Max: 45 + +Style/StringLiterals: + EnforcedStyle: single_quotes + +Layout/DotPosition: + EnforcedStyle: trailing + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/RegexpLiteral: + Enabled: false + +Style/WordArray: + Enabled: false + +Style/SymbolArray: + Enabled: false + +Style/GuardClause: + Enabled: false + +Style/TrailingCommaInArrayLiteral: + Enabled: false + +Style/TrailingCommaInHashLiteral: + Enabled: false + +Style/BarePercentLiterals: + Enabled: false + +Style/MutableConstant: + Enabled: false + +Style/PercentLiteralDelimiters: + Enabled: false + +Style/IfUnlessModifier: + Enabled: false + +Naming/VariableNumber: + Enabled: false + +Performance/RegexpMatch: + Enabled: false + +Style/UnneededPercentQ: + Enabled: false + +Lint/ParenthesesAsGroupedExpression: + Enabled: false + +Style/NumericPredicate: + Enabled: false + +Metrics/PerceivedComplexity: + Max: 10 + +Metrics/CyclomaticComplexity: + Max: 10 + +Style/ClassAndModuleChildren: + Enabled: false + +Style/AndOr: + Exclude: + - '**/*controller.rb' + +RSpec/NestedGroups: + Max: 7 + +Lint/AmbiguousBlockAssociation: + Exclude: + - '**/spec/**/*' + +Style/NumericLiterals: + Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000000..73462a5a134 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.5.1 diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 00000000000..52462a25269 --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,5 @@ +**/vendor/** +**/dummy/** +**/sandbox/** +**/guides/output/** +/cmd/lib/spree_cmd/templates/** diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 00000000000..0c4829ceac0 --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,6 @@ +{ + "extends": "stylelint-config-recommended", + "rules": { + "at-rule-no-unknown": null + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..22645affa38 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +### Please leave CHANGELOG entries in the appropriate release notes guide. ## + +https://github.com/spree/spree/tree/master/guides/src/content/release_notes + +#### 3.7.x (Edge) + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_7_0.md + +#### 3.6.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_6_0.md + +#### 3.5.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_5_0.md + +#### 3.4.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_4_0.md + +#### 3.3.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_3_0.md + +#### 3.2.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_2_0.md + +#### 3.1.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_1_0.md + +#### 3.0.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/3_0_0.md + +#### 2.4.x + +https://github.com/spree/spree/blob/master/guides/src/content/release_notes/2_4_0.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..01b8644f13a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,22 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..78b0b7184a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# This dockerfile is used to build sandbox image for docker clouds. It's not meant to be used in projects +FROM ruby:2.5.1 +RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs +RUN mkdir /spree +WORKDIR /spree +ADD . /spree +RUN bundle install +RUN bundle exec rake sandbox +CMD ["sh", "docker-entrypoint.sh"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000000..c74b2a60ce2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +eval(File.read(File.dirname(__FILE__) + '/common_spree_dependencies.rb')) + +gemspec diff --git a/License.txt b/License.txt deleted file mode 100644 index fd599779576..00000000000 --- a/License.txt +++ /dev/null @@ -1,26 +0,0 @@ -Copyright (c) 2007-2008, Schofield Tech Works LLC and other contributors -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of the Schofield Tech Works LLC nor the names of its - contributors may be used to endorse or promote products derived from this - software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 00000000000..d3e6c0a6716 --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ + + + +* Join our Slack at [slack.spreecommerce.org](http://slack.spreecommerce.org/) +* [Success Stories](https://spreecommerce.org/stories/) +* [Integrations](https://spreecommerce.org/integrations/) +* [Extensions](https://github.com/spree/spree#extensions) +* [Documentation](http://guides.spreecommerce.org) +* [Roadmap](https://github.com/spree/spree/milestones?direction=asc&sort=due_date&state=open) +* [Maintenance Policy](https://github.com/spree/spree/wiki/Maintenance-Policy) + +[![Gem Version](https://badge.fury.io/rb/spree.svg)](https://badge.fury.io/rb/spree) [![Circle CI](https://circleci.com/gh/spree/spree.svg?style=shield)](https://circleci.com/gh/spree/spree/tree/master) +[![Code Climate](https://codeclimate.com/github/spree/spree.svg)](https://codeclimate.com/github/spree/spree) +[![Test Coverage](https://api.codeclimate.com/v1/badges/8277fc2bb0b1f777084f/test_coverage)](https://codeclimate.com/github/spree/spree/test_coverage) +[![Slack Status](http://slack.spreecommerce.org/badge.svg)](http://slack.spreecommerce.org) + +**Spree** is a complete open source e-commerce solution built with Ruby on Rails. It +was originally developed by Sean Schofield and is now maintained by [Spark Solutions](http://sparksolutions.co). We're open to [contributions](#contributing) and accepting new [Core Team](https://github.com/spree/spree/wiki/Core-Team) members. + +Spree consists of several different gems, each of which are maintained +in a single repository and documented in a single set of +[online documentation](http://guides.spreecommerce.org/). + +* **spree_api** (new REST API v2 and legacy REST API v1, [GraphQL support](https://github.com/spree/spree/issues/9176) coming soon) +* **spree_frontend** (default Rails customer frontend) +* **spree_backend** (Admin Panel) +* **spree_cmd** (Command-line tools) +* **spree_core** (Models, Services & Mailers, the basic components of Spree that it can't run without) +* **spree_sample** (Sample data) + +You don't need to install all of the components. Only the **Core** is mandatory. + +Demo +---- + +[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/spree/spree/tree/3-6-stable) + +If you want to run the demo on your local machine, you can use our docker image. It will download and run sample Spree application on http://localhost:3000 +```shell +docker run --rm -it -p 3000:3000 spreecommerce/spree:3.6.4 +``` + +Admin Panel credentials - login: `spree@example.com` / password: `spree123` + + +Getting Started +---------------------- + +Add Spree gems to your `Gemfile`: + +### Rails 5.2 + +```ruby +gem 'spree', '~> 3.7.0.rc1' +gem 'spree_auth_devise', '~> 3.4' +gem 'spree_gateway', '~> 3.4' +``` + +Rails 5.2 versions come with [ActiveStorage support](https://spreecommerce.org/spree-3-5-and-3-6-with-rails-5-2-ruby-2-5-and-activestorage-support-released/). You can still use Paperclip (see [instructions](https://guides.spreecommerce.org/developer/images.html#paperclip)). + +### Rails 5.1 + +```ruby +gem 'spree', '~> 3.5.0' +gem 'spree_auth_devise', '~> 3.4' +gem 'spree_gateway', '~> 3.4' +``` + +### Rails 5.0 + +```ruby +gem 'spree', '~> 3.2.7' +gem 'spree_auth_devise', '~> 3.4' +gem 'spree_gateway', '~> 3.4' +``` + + +Run `bundle install` + +Use the install generators to set up Spree: + +```shell +rails g spree:install --user_class=Spree::User +rails g spree:auth:install +rails g spree_gateway:install +``` + +Installation options +---------------------- + +Alternatively, if you want to use the bleeding edge version of Spree, add this to your Gemfile: + +```ruby +gem 'spree', github: 'spree/spree' +gem 'spree_auth_devise', github: 'spree/spree_auth_devise' +gem 'spree_gateway', github: 'spree/spree_gateway' +``` + +**Note: The master branch is not guaranteed to ever be in a fully functioning +state. It is unwise to use this branch in a production system you care deeply +about.** + +By default, the installation generator (`rails g spree:install`) will run +migrations as well as adding seed and sample data and will copy frontend views +for easy customization (if spree_frontend available). This can be disabled using + +```shell +rails g spree:install --migrate=false --sample=false --seed=false --copy_views=false +``` + +You can always perform any of these steps later by using these commands. + +```shell +bundle exec rake railties:install:migrations +bundle exec rake db:migrate +bundle exec rake db:seed +bundle exec rake spree_sample:load +``` + +Browse Store +---------------------- + +http://localhost:3000 + +Browse Admin Interface +---------------------- + +http://localhost:3000/admin + +If you have `spree_auth_devise` installed, you can generate a new admin user by running `rake spree_auth:admin:create`. + +Extensions +---------------------- + +Spree Extensions provide additional features not present in the Core system. + + +| Extension | Spree 3.2+ support | Description | +| --- | --- | --- | +| [spree_gateway](https://github.com/spree/spree_gateway) | [![Build Status](https://travis-ci.org/spree/spree_gateway.svg?branch=master)](https://travis-ci.org/spree/spree_gateway) | Community supported Spree Payment Method Gateways +| [spree_auth_devise](https://github.com/spree/spree_auth_devise) | [![Build Status](https://travis-ci.org/spree/spree_auth_devise.svg?branch=master)](https://travis-ci.org/spree/spree_auth_devise) | Provides authentication services for Spree, using the Devise gem. +| [spree_i18n](https://github.com/spree-contrib/spree_i18n) | [![Build Status](https://travis-ci.org/spree-contrib/spree_i18n.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_i18n) | I18n translation files for Spree Commerce +| [spree-multi-domain](https://github.com/spree-contrib/spree-multi-domain) | [![Build Status](https://travis-ci.org/spree-contrib/spree-multi-domain.svg?branch=master)](https://travis-ci.org/spree-contrib/spree-multi-domain) | Multiple Spree stores on different domains - single unified backed for processing orders +| [spree_multi_currency](https://github.com/spree-contrib/spree_multi_currency) | [![Build Status](https://travis-ci.org/spree-contrib/spree_multi_currency.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_multi_currency) | Provides UI to allow configuring multiple currencies in Spree | +| [spree_multi_vendor](https://github.com/spree-contrib/spree_multi_vendor) | [![Build Status](https://travis-ci.org/spree-contrib/spree_multi_vendor.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_multi_vendor) | Spree Multi Vendor Marketplace extension | +| [spree-mollie-gateway](https://github.com/mollie/spree-mollie-gateway) | [![Build Status](https://travis-ci.org/mollie/spree-mollie-gateway.svg?branch=master)](https://github.com/mollie/spree-mollie-gateway) | Official [Mollie](https://www.mollie.com) payment gateway for Spree Commerce. | +| [spree_braintree_vzero](https://github.com/spree-contrib/spree_braintree_vzero) | [![Build Status](https://travis-ci.org/spree-contrib/spree_braintree_vzero.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_braintree_vzero) | Official Spree Braintree v.zero + PayPal extension | +| [spree_address_book](https://github.com/spree-contrib/spree_address_book) | [![Build Status](https://travis-ci.org/spree-contrib/spree_address_book.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_address_book) | Adds address book for users to Spree | +| [spree_digital](https://github.com/spree-contrib/spree_digital) | [![Build Status](https://travis-ci.org/spree-contrib/spree_digital.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_digital) | A Spree extension to enable downloadable products | +| [spree_social](https://github.com/spree-contrib/spree_social) |[![Build Status](https://travis-ci.org/spree-contrib/spree_social.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_social) | Building block for spree social networking features (provides authentication and account linkage) | +| [spree_related_products](https://github.com/spree-contrib/spree_related_products) | [![Build Status](https://travis-ci.org/spree-contrib/spree_related_products.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_related_products) | Related products extension for Spree +| [spree_static_content](https://github.com/spree-contrib/spree_static_content) | [![Build Status](https://travis-ci.org/spree-contrib/spree_static_content.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_static_content) | Manage static pages for Spree | +| [spree-product-assembly](https://github.com/spree-contrib/spree-product-assembly) | [![Build Status](https://travis-ci.org/spree-contrib/spree-product-assembly.svg?branch=master)](https://travis-ci.org/spree-contrib/spree-product-assembly) | Adds oportunity to make bundle of products | +| [spree_editor](https://github.com/spree-contrib/spree_editor) | [![Build Status](https://travis-ci.org/spree-contrib/spree_editor.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_editor) | Rich text editor for Spree with Image and File uploading in-place | +| [spree_recently_viewed](https://github.com/spree-contrib/spree_recently_viewed) | [![Build Status](https://travis-ci.org/spree-contrib/spree_recently_viewed.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_recently_viewed) | Recently viewed products in Spree | +| [spree_wishlist](https://github.com/spree-contrib/spree_wishlist) | [![Build Status](https://travis-ci.org/spree-contrib/spree_wishlist.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_wishlist) | Wishlist extension for Spree | +| [spree_sitemap](https://github.com/spree-contrib/spree_sitemap) | [![Build Status](https://travis-ci.org/spree-contrib/spree_sitemap.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_sitemap) | Sitemap Generator for Spree | +| [spree_volume_pricing](https://github.com/spree-contrib/spree_volume_pricing) | [![Build Status](https://travis-ci.org/spree-contrib/spree_volume_pricing.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_volume_pricing) | It determines the price for a particular product variant with predefined ranges of quantities +| [better_spree_paypal_express](https://github.com/spree-contrib/better_spree_paypal_express) | [![Build Status](https://travis-ci.org/spree-contrib/better_spree_paypal_express.svg?branch=master)](https://travis-ci.org/spree-contrib/better_spree_paypal_express) | This is the official Paypal Express extension for Spree. +| [spree_globalize](https://github.com/spree-contrib/spree_globalize) | [![Build Status](https://travis-ci.org/spree-contrib/spree_globalize.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_globalize) | Adds support for model translations (multi-language stores) +| [spree_avatax_certified](https://github.com/spree-contrib/spree_avatax_certified) | [![Build Status](https://travis-ci.org/spree-contrib/spree_avatax_certified.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_avatax_certified) | Improve your Spree store's sales tax decision automation with Avalara AvaTax +| [spree_analytics_trackers](https://github.com/spree-contrib/spree_analytics_trackers) | [![Build Status](https://travis-ci.org/spree-contrib/spree_analytics_trackers.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_analytics_trackers) | Adds support for Analytics Trackers (Google Analytics & Segment) + +Performance +---------------------- + +You may notice that your Spree store runs slowly in development environment. This can be because in development each asset (css and javascript) is loaded separately. You can disable it by adding the following line to `config/environments/development.rb`. + +```ruby +config.assets.debug = false +``` + + +Developing Spree +---------------------- + +Spree is meant to be run within the context of Rails application and the source code is essentially a collection of gems. You can easily create a sandbox +application inside of your cloned source directory for testing purposes. + + +Clone the Git repo + +```shell +git clone git://github.com/spree/spree.git +cd spree +``` + +Install the gem dependencies + +```shell +bundle install +``` + +### Sandbox + +Create a sandbox Rails application for testing purposes which automatically perform all necessary database setup + +```shell +bundle exec rake sandbox +``` + +Start the server + +```shell +cd sandbox +rails server +``` + +### Running Tests + +We use [CircleCI](https://circleci.com/) to run the tests for Spree. + +You can see the build statuses at [https://circleci.com/gh/spree/spree](https://circleci.com/gh/spree/spree). + +--- + +Each gem contains its own series of tests, and for each directory, you need to +do a quick one-time creation of a test application and then you can use it to run +the tests. For example, to run the tests for the core project. +```shell +cd core +BUNDLE_GEMFILE=../Gemfile bundle exec rake test_app +bundle exec rspec spec +``` + +If you would like to run specs against a particular database you may specify the +dummy app's database, which defaults to sqlite3. +```shell +DB=postgres bundle exec rake test_app +``` + +If you want to run specs for only a single spec file +```shell +bundle exec rspec spec/models/spree/state_spec.rb +``` + +If you want to run a particular line of spec +```shell +bundle exec rspec spec/models/spree/state_spec.rb:7 +``` + +You can also enable fail fast in order to stop tests at the first failure +```shell +FAIL_FAST=true bundle exec rspec spec/models/state_spec.rb +``` + +If you want to run the simplecov code coverage report +```shell +COVERAGE=true bundle exec rspec spec +``` + +If you're working on multiple facets of Spree to test, +please ensure that you have a postgres user: + +```shell +createuser -s -r postgres +``` + +And also ensure that you have [ChromeDriver](http://chromedriver.chromium.org) installed as well. +Please follow this +[instruction](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver) to install it. + +To execute all the tests, you may want to run this command at the +root of the Spree project to generate test applications and run +specs for all the facets: +```shell +bash build.sh +``` + + +Contributing +---------------------- + +Spree is an open source project and we encourage contributions. Please review the +[contributing guidelines](https://github.com/spree/spree/blob/master/.github/CONTRIBUTING.md) +before contributing. + +In the spirit of [free software](http://www.fsf.org/licensing/essays/free-sw.html), **everyone** is encouraged to help improve this project. + +Here are some ways **you** can contribute: + +* by using prerelease versions / master branch +* by reporting [bugs](https://github.com/spree/spree/issues/new) +* by [translating to a new language](https://github.com/spree/spree_i18n/tree/master/config/locales) +* by writing or editing [documentation](https://github.com/spree/spree/blob/master/.github/CONTRIBUTING.md) +* by writing [specs](https://github.com/spree/spree/labels/need_specs) +* by writing [needed code](https://github.com/spree/spree/labels/feature_request) or [finishing code](https://github.com/spree/spree/labels/address_feedback) +* by [refactoring code](https://github.com/spree/spree/labels/address_feedback) +* by reviewing [pull requests](https://github.com/spree/spree/pulls) +* by verifying [issues](https://github.com/spree/spree/labels/unverified) + +License +---------------------- + +Spree is released under the [New BSD License](https://github.com/spree/spree/blob/master/license.md). + + +About Spark Solutions +---------------------- +[![Spark Solutions](http://sparksolutions.co/wp-content/uploads/2015/01/logo-ss-tr-221x100.png)][spark] + +Spree is maintained by [Spark Solutions Sp. z o.o.][spark]. + +We are passionate about open source software. +We are [available for hire][spark]. + +[spark]:http://sparksolutions.co?utm_source=github diff --git a/README.txt b/README.txt deleted file mode 100644 index dc263cd5f70..00000000000 --- a/README.txt +++ /dev/null @@ -1,49 +0,0 @@ -SUMMARY -======= - -Spree is a complete open source commerce solution for Ruby on Rails. It was developed by Sean Schofield under the original name of Rails Cart before changing its name to Spree. - -QUICK START -=========== - -1.) Install spree Gem - -$ sudo gem install spree - -2.) Create Spree Application - -$ spree app_name - -3.) Create MySQL Database - -mysql> create database spree_dev; -mysql> grant all privileges on spree_dev.* to 'spree'@'localhost' identified by 'spree'; -mysql> flush privileges; - -4.) Migrations - -$ cd app_name -$ rake db:migrate - -5.) Bootstrap - -Spree requires an admin user and a few other pieces of structural data to be loaded into your database. - -rake spree:bootstrap - -6.) Sample Data (Optional) - -Optionally load some sample data so you have something to look at. - -rake spree:sample_data - -7.) Launch Application - -Browse Store - -http://localhost:xxxx/store - -Admin Interface (user: admin password: test) - -http://localhost:xxxx/admin - diff --git a/Rakefile b/Rakefile index 3bb0e8592a4..6f379944a86 100644 --- a/Rakefile +++ b/Rakefile @@ -1,10 +1,98 @@ -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. +require 'rake' +require 'rubygems/package_task' +require 'thor/group' +begin + require 'spree/testing_support/common_rake' +rescue LoadError + raise "Could not find spree/testing_support/common_rake. You need to run this command using Bundler." +end -require(File.join(File.dirname(__FILE__), 'config', 'boot')) +SPREE_GEMS = %w(core api cmd backend frontend sample).freeze -require 'rake' -require 'rake/testtask' -require 'rake/rdoctask' +task default: :test + +desc "Runs all tests in all Spree engines" +task test: :test_app do + SPREE_GEMS.each do |gem_name| + Dir.chdir("#{File.dirname(__FILE__)}/#{gem_name}") do + sh 'rspec' + end + end +end + +desc "Generates a dummy app for testing for every Spree engine" +task :test_app do + SPREE_GEMS.each do |gem_name| + Dir.chdir("#{File.dirname(__FILE__)}/#{gem_name}") do + sh 'rake test_app' + end + end +end + +desc "clean the whole repository by removing all the generated files" +task :clean do + rm_f "Gemfile.lock" + rm_rf "sandbox" + rm_rf "pkg" + + SPREE_GEMS.each do |gem_name| + rm_f "#{gem_name}/Gemfile.lock" + rm_rf "#{gem_name}/pkg" + rm_rf "#{gem_name}/spec/dummy" + end +end + +namespace :gem do + def version + require 'spree/core/version' + Spree.version + end + + def for_each_gem + SPREE_GEMS.each do |gem_name| + yield "pkg/spree_#{gem_name}-#{version}.gem" + end + yield "pkg/spree-#{version}.gem" + end + + desc "Build all spree gems" + task :build do + pkgdir = File.expand_path("../pkg", __FILE__) + FileUtils.mkdir_p pkgdir + + SPREE_GEMS.each do |gem_name| + Dir.chdir(gem_name) do + sh "gem build spree_#{gem_name}.gemspec" + mv "spree_#{gem_name}-#{version}.gem", pkgdir + end + end + + sh "gem build spree.gemspec" + mv "spree-#{version}.gem", pkgdir + end + + desc "Install all spree gems" + task install: :build do + for_each_gem do |gem_path| + Bundler.with_clean_env do + sh "gem install #{gem_path}" + end + end + end + + desc "Release all gems to rubygems" + task release: :build do + sh "git tag -a -m \"Version #{version}\" v#{version}" + + for_each_gem do |gem_path| + sh "gem push '#{gem_path}'" + end + end +end -require 'tasks/rails' +desc "Creates a sandbox application for simulating the Spree code in a deployed Rails app" +task :sandbox do + Bundler.with_clean_env do + exec("lib/sandbox.sh") + end +end diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 00000000000..d87d4be66f4 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,17 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/api/Gemfile b/api/Gemfile new file mode 100644 index 00000000000..f0bccbfd978 --- /dev/null +++ b/api/Gemfile @@ -0,0 +1,5 @@ +eval(File.read(File.dirname(__FILE__) + '/../common_spree_dependencies.rb')) + +gem 'spree_core', path: '../core' + +gemspec diff --git a/api/LICENSE b/api/LICENSE new file mode 100644 index 00000000000..3b57c94ed0e --- /dev/null +++ b/api/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2007-2015, Spree Commerce, Inc. and other contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name Spree nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/api/Rakefile b/api/Rakefile new file mode 100644 index 00000000000..b9121c35249 --- /dev/null +++ b/api/Rakefile @@ -0,0 +1,16 @@ +require 'rubygems' +require 'rake' +require 'rake/testtask' +require 'rspec/core/rake_task' +require 'spree/testing_support/common_rake' +require 'rails/all' + +RSpec::Core::RakeTask.new + +task default: :spec + +desc "Generates a dummy app for testing" +task :test_app do + ENV['LIB_NAME'] = 'spree/api' + Rake::Task['common:test_app'].invoke +end diff --git a/api/app/assets/javascripts/spree/api/main.js b/api/app/assets/javascripts/spree/api/main.js new file mode 100644 index 00000000000..dc4e5d1bca1 --- /dev/null +++ b/api/app/assets/javascripts/spree/api/main.js @@ -0,0 +1,36 @@ +//= require spree + +var SpreeAPI = { + oauthToken: null, // user Bearer token to authorize operations for the given user + orderToken: null // order token to authorize operations on current order (cart) +} + +SpreeAPI.Storefront = {} +SpreeAPI.Platform = {} + +// API routes +Spree.routes.api_v2_storefront_cart_create = Spree.pathFor('api/v2/storefront/cart') +Spree.routes.api_v2_storefront_cart_add_item = Spree.pathFor('api/v2/storefront/cart/add_item') +Spree.routes.api_v2_storefront_cart_apply_coupon_code = Spree.pathFor('api/v2/storefront/cart/apply_coupon_code') + +// helpers +SpreeAPI.handle500error = function () { + alert('Internal Server Error') +} + +SpreeAPI.prepareHeaders = function (headers) { + if (typeof headers === 'undefined') { + headers = {} + } + + // if signed in we need to pass the Bearer authorization token + // so backend will recognize that actions are authorized in scope of this user + if (SpreeAPI.oauthToken) { + headers['Authorization'] = 'Bearer ' + SpreeAPI.oauthToken + } + + // default headers, required for POST/PATCH/DELETE requests + headers['Accept'] = 'application/json' + headers['Content-Type'] = 'application/json' + return headers +} diff --git a/api/app/assets/javascripts/spree/api/storefront/cart.js b/api/app/assets/javascripts/spree/api/storefront/cart.js new file mode 100644 index 00000000000..8bebe2e706a --- /dev/null +++ b/api/app/assets/javascripts/spree/api/storefront/cart.js @@ -0,0 +1,49 @@ +//= require spree/api/main + +SpreeAPI.Storefront.createCart = function (successCallback, failureCallback) { + fetch(Spree.routes.api_v2_storefront_cart_create, { + method: 'POST', + headers: SpreeAPI.prepareHeaders() + }).then(function (response) { + switch (response.status) { + case 422: + response.json().then(function (json) { failureCallback(json.error) }) + break + case 500: + SpreeAPI.handle500error() + break + case 201: + response.json().then(function (json) { + SpreeAPI.orderToken = json.data.attributes.token + successCallback() + }) + break + } + }) +} + +SpreeAPI.Storefront.addToCart = function (variantId, quantity, options, successCallback, failureCallback) { + fetch(Spree.routes.api_v2_storefront_cart_add_item, { + method: 'POST', + headers: SpreeAPI.prepareHeaders({ 'X-Spree-Order-Token': SpreeAPI.orderToken }), + body: JSON.stringify({ + variant_id: variantId, + quantity: quantity, + options: options + }) + }).then(function (response) { + switch (response.status) { + case 422: + response.json().then(function (json) { failureCallback(json.error) }) + break + case 500: + SpreeAPI.handle500error() + break + case 200: + response.json().then(function (json) { + successCallback(json.data) + }) + break + } + }) +} diff --git a/api/app/controllers/concerns/spree/api/v2/storefront/order_concern.rb b/api/app/controllers/concerns/spree/api/v2/storefront/order_concern.rb new file mode 100644 index 00000000000..3a6b9bfcf8e --- /dev/null +++ b/api/app/controllers/concerns/spree/api/v2/storefront/order_concern.rb @@ -0,0 +1,48 @@ +module Spree + module Api + module V2 + module Storefront + module OrderConcern + private + + def render_order(result) + if result.success? + render_serialized_payload { serialized_current_order } + else + render_error_payload(result.error) + end + end + + def ensure_order + raise ActiveRecord::RecordNotFound if spree_current_order.nil? + end + + def order_token + request.headers['X-Spree-Order-Token'] || params[:order_token] + end + + def spree_current_order + @spree_current_order ||= find_spree_current_order + end + + def find_spree_current_order + Spree::Api::Dependencies.storefront_current_order_finder.constantize.new.execute( + store: spree_current_store, + user: spree_current_user, + token: order_token, + currency: current_currency + ) + end + + def serialize_order(order) + resource_serializer.new(order.reload, include: resource_includes, fields: sparse_fields).serializable_hash + end + + def serialized_current_order + serialize_order(spree_current_order) + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/base_controller.rb b/api/app/controllers/spree/api/base_controller.rb new file mode 100644 index 00000000000..2d405dadc29 --- /dev/null +++ b/api/app/controllers/spree/api/base_controller.rb @@ -0,0 +1,165 @@ +require_dependency 'spree/api/controller_setup' + +module Spree + module Api + class BaseController < ActionController::Base + protect_from_forgery unless: -> { request.format.json? || request.format.xml? } + + include Spree::Api::ControllerSetup + include Spree::Core::ControllerHelpers::Store + include Spree::Core::ControllerHelpers::StrongParameters + + attr_accessor :current_api_user + + before_action :set_content_type + before_action :load_user + before_action :authorize_for_order, if: proc { order_token.present? } + before_action :authenticate_user + before_action :load_user_roles + + rescue_from ActionController::ParameterMissing, with: :error_during_processing + rescue_from ActiveRecord::RecordInvalid, with: :error_during_processing + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from CanCan::AccessDenied, with: :unauthorized + rescue_from Spree::Core::GatewayError, with: :gateway_error + + helper Spree::Api::ApiHelpers + + # users should be able to set price when importing orders via api + def permitted_line_item_attributes + if @current_user_roles.include?('admin') + super + [:price, :variant_id, :sku] + else + super + end + end + + def content_type + case params[:format] + when 'json' + 'application/json; charset=utf-8' + when 'xml' + 'text/xml; charset=utf-8' + end + end + + private + + def set_content_type + headers['Content-Type'] = content_type + end + + def load_user + @current_api_user = Spree.user_class.find_by(spree_api_key: api_key.to_s) + end + + def authenticate_user + return if @current_api_user + + if requires_authentication? && api_key.blank? && order_token.blank? + must_specify_api_key and return + elsif order_token.blank? && (requires_authentication? || api_key.present?) + invalid_api_key and return + else + # An anonymous user + @current_api_user = Spree.user_class.new + end + end + + def invalid_api_key + render 'spree/api/errors/invalid_api_key', status: 401 + end + + def must_specify_api_key + render 'spree/api/errors/must_specify_api_key', status: 401 + end + + def load_user_roles + @current_user_roles = @current_api_user ? @current_api_user.spree_roles.pluck(:name) : [] + end + + def unauthorized + render 'spree/api/errors/unauthorized', status: 401 and return + end + + def error_during_processing(exception) + Rails.logger.error exception.message + Rails.logger.error exception.backtrace.join("\n") + + unprocessable_entity(exception.message) + end + + def unprocessable_entity(message) + render plain: { exception: message }.to_json, status: 422 + end + + def gateway_error(exception) + @order.errors.add(:base, exception.message) + invalid_resource!(@order) + end + + def requires_authentication? + Spree::Api::Config[:requires_authentication] + end + + def not_found + render 'spree/api/errors/not_found', status: 404 and return + end + + def current_ability + Spree::Dependencies.ability_class.constantize.new(current_api_user) + end + + def invalid_resource!(resource) + @resource = resource + render 'spree/api/errors/invalid_resource', status: 422 + end + + def api_key + request.headers['X-Spree-Token'] || params[:token] + end + helper_method :api_key + + def order_token + request.headers['X-Spree-Order-Token'] || params[:order_token] + end + + def find_product(id) + @product = product_scope.friendly.distinct(false).find(id.to_s) + rescue ActiveRecord::RecordNotFound + @product = product_scope.find_by(id: id) + not_found unless @product + end + + def product_scope + if @current_user_roles.include?('admin') + scope = Product.with_deleted.accessible_by(current_ability, :read).includes(*product_includes) + + scope = scope.not_deleted unless params[:show_deleted] + scope = scope.not_discontinued unless params[:show_discontinued] + else + scope = Product.accessible_by(current_ability, :read).active.includes(*product_includes) + end + + scope + end + + def variants_associations + [{ option_values: :option_type }, :default_price, :images] + end + + def product_includes + [:option_types, :taxons, product_properties: :property, variants: variants_associations, master: variants_associations] + end + + def order_id + params[:order_id] || params[:checkout_id] || params[:order_number] + end + + def authorize_for_order + @order = Spree::Order.find_by(number: order_id) + authorize! :read, @order, order_token + end + end + end +end diff --git a/api/app/controllers/spree/api/errors_controller.rb b/api/app/controllers/spree/api/errors_controller.rb new file mode 100644 index 00000000000..9105b9fab9e --- /dev/null +++ b/api/app/controllers/spree/api/errors_controller.rb @@ -0,0 +1,9 @@ +module Spree + module Api + class ErrorsController < ActionController::Base + def render_404 + render 'spree/api/errors/not_found', status: 404 + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/addresses_controller.rb b/api/app/controllers/spree/api/v1/addresses_controller.rb new file mode 100644 index 00000000000..459384ec464 --- /dev/null +++ b/api/app/controllers/spree/api/v1/addresses_controller.rb @@ -0,0 +1,46 @@ +module Spree + module Api + module V1 + class AddressesController < Spree::Api::BaseController + before_action :find_order + + def show + authorize! :read, @order, order_token + @address = find_address + respond_with(@address) + end + + def update + authorize! :update, @order, order_token + @address = find_address + + if @address.update_attributes(address_params) + respond_with(@address, default_template: :show) + else + invalid_resource!(@address) + end + end + + private + + def address_params + params.require(:address).permit(permitted_address_attributes) + end + + def find_order + @order = Spree::Order.find_by!(number: order_id) + end + + def find_address + if @order.bill_address_id == params[:id].to_i + @order.bill_address + elsif @order.ship_address_id == params[:id].to_i + @order.ship_address + else + raise CanCan::AccessDenied + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/checkouts_controller.rb b/api/app/controllers/spree/api/v1/checkouts_controller.rb new file mode 100644 index 00000000000..a3cb0954697 --- /dev/null +++ b/api/app/controllers/spree/api/v1/checkouts_controller.rb @@ -0,0 +1,112 @@ +module Spree + module Api + module V1 + class CheckoutsController < Spree::Api::BaseController + before_action :associate_user, only: :update + before_action :load_order_with_lock, only: [:next, :advance, :update] + + include Spree::Core::ControllerHelpers::Auth + include Spree::Core::ControllerHelpers::Order + # This before_action comes from Spree::Core::ControllerHelpers::Order + skip_before_action :set_current_order + + def next + authorize! :update, @order, order_token + @order.next! + respond_with(@order, default_template: 'spree/api/v1/orders/show', status: 200) + rescue StateMachines::InvalidTransition + respond_with(@order, default_template: 'spree/api/v1/orders/could_not_transition', status: 422) + end + + def advance + authorize! :update, @order, order_token + while @order.next; end + respond_with(@order, default_template: 'spree/api/v1/orders/show', status: 200) + end + + def update + authorize! :update, @order, order_token + + if @order.update_from_params(params, permitted_checkout_attributes, request.headers.env) + if current_api_user.has_spree_role?('admin') && user_id.present? + @order.associate_user!(Spree.user_class.find(user_id)) + end + + log_state_changes if params[:state] + + return if after_update_attributes + + if @order.completed? || @order.next + state_callback(:after) + respond_with(@order, default_template: 'spree/api/v1/orders/show') + else + respond_with(@order, default_template: 'spree/api/v1/orders/could_not_transition', status: 422) + end + else + invalid_resource!(@order) + end + end + + private + + def user_id + params[:order][:user_id] if params[:order] + end + + # Should be overriden if you have areas of your checkout that don't match + # up to a step within checkout_steps, such as a registration step + def skip_state_validation? + false + end + + def load_order(lock = false) + @order = Spree::Order.lock(lock).find_by!(number: params[:id]) + raise_insufficient_quantity and return if @order.insufficient_stock_lines.present? + @order.state = params[:state] if params[:state] + state_callback(:before) + end + + def load_order_with_lock + load_order(true) + end + + def raise_insufficient_quantity + respond_with(@order, default_template: 'spree/api/v1/orders/insufficient_quantity', status: 422) + end + + def state_callback(before_or_after = :before) + method_name = :"#{before_or_after}_#{@order.state}" + send(method_name) if respond_to?(method_name, true) + end + + def after_update_attributes + if params[:order] && params[:order][:coupon_code].present? + handler = PromotionHandler::Coupon.new(@order) + handler.apply + + if handler.error.present? + @coupon_message = handler.error + respond_with(@order, default_template: 'spree/api/v1/orders/could_not_apply_coupon', status: 422) + return true + end + end + false + end + + def log_state_changes + if @order.previous_changes[:state] + @order.log_state_changes( + state_name: 'order', + old_state: @order.previous_changes[:state].first, + new_state: @order.previous_changes[:state].last + ) + end + end + + def order_id + super || params[:id] + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/classifications_controller.rb b/api/app/controllers/spree/api/v1/classifications_controller.rb new file mode 100644 index 00000000000..4cdc52a315d --- /dev/null +++ b/api/app/controllers/spree/api/v1/classifications_controller.rb @@ -0,0 +1,20 @@ +module Spree + module Api + module V1 + class ClassificationsController < Spree::Api::BaseController + def update + authorize! :update, Product + authorize! :update, Taxon + classification = Spree::Classification.find_by( + product_id: params[:product_id], + taxon_id: params[:taxon_id] + ) + # Because position we get back is 0-indexed. + # acts_as_list is 1-indexed. + classification.insert_at(params[:position].to_i + 1) + head :ok + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/countries_controller.rb b/api/app/controllers/spree/api/v1/countries_controller.rb new file mode 100644 index 00000000000..bbe89599ec8 --- /dev/null +++ b/api/app/controllers/spree/api/v1/countries_controller.rb @@ -0,0 +1,22 @@ +module Spree + module Api + module V1 + class CountriesController < Spree::Api::BaseController + skip_before_action :authenticate_user + + def index + @countries = Country.accessible_by(current_ability, :read).ransack(params[:q]).result. + order('name ASC'). + page(params[:page]).per(params[:per_page]) + country = Country.order('updated_at ASC').last + respond_with(@countries) if stale?(country) + end + + def show + @country = Country.accessible_by(current_ability, :read).find(params[:id]) + respond_with(@country) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/credit_cards_controller.rb b/api/app/controllers/spree/api/v1/credit_cards_controller.rb new file mode 100644 index 00000000000..ecc56b2a111 --- /dev/null +++ b/api/app/controllers/spree/api/v1/credit_cards_controller.rb @@ -0,0 +1,26 @@ +module Spree + module Api + module V1 + class CreditCardsController < Spree::Api::BaseController + before_action :user + + def index + @credit_cards = user. + credit_cards. + accessible_by(current_ability, :read). + with_payment_profile. + ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@credit_cards) + end + + private + + def user + if params[:user_id].present? + @user ||= Spree.user_class.accessible_by(current_ability, :read).find(params[:user_id]) + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/customer_returns_controller.rb b/api/app/controllers/spree/api/v1/customer_returns_controller.rb new file mode 100644 index 00000000000..41f11e6fff0 --- /dev/null +++ b/api/app/controllers/spree/api/v1/customer_returns_controller.rb @@ -0,0 +1,25 @@ +module Spree + module Api + module V1 + class CustomerReturnsController < Spree::Api::BaseController + def index + collection(Spree::CustomerReturn) + respond_with(@collection) + end + + private + + def collection(resource) + return @collection if @collection.present? + + params[:q] ||= {} + + @collection = resource.all + # @search needs to be defined as this is passed to search_form_for + @search = @collection.ransack(params[:q]) + @collection = @search.result.order(created_at: :desc).page(params[:page]).per(params[:per_page]) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/images_controller.rb b/api/app/controllers/spree/api/v1/images_controller.rb new file mode 100644 index 00000000000..f587f18cc97 --- /dev/null +++ b/api/app/controllers/spree/api/v1/images_controller.rb @@ -0,0 +1,58 @@ +module Spree + module Api + module V1 + class ImagesController < Spree::Api::BaseController + def index + @images = scope.images.accessible_by(current_ability, :read) + respond_with(@images) + end + + def show + @image = Image.accessible_by(current_ability, :read).find(params[:id]) + respond_with(@image) + end + + def new; end + + def create + authorize! :create, Image + @image = scope.images.new(image_params) + if @image.save + respond_with(@image, status: 201, default_template: :show) + else + invalid_resource!(@image) + end + end + + def update + @image = scope.images.accessible_by(current_ability, :update).find(params[:id]) + if @image.update_attributes(image_params) + respond_with(@image, default_template: :show) + else + invalid_resource!(@image) + end + end + + def destroy + @image = scope.images.accessible_by(current_ability, :destroy).find(params[:id]) + @image.destroy + respond_with(@image, status: 204) + end + + private + + def image_params + params.require(:image).permit(permitted_image_attributes) + end + + def scope + if params[:product_id] + Spree::Product.friendly.find(params[:product_id]) + elsif params[:variant_id] + Spree::Variant.find(params[:variant_id]) + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/inventory_units_controller.rb b/api/app/controllers/spree/api/v1/inventory_units_controller.rb new file mode 100644 index 00000000000..4fe13923b98 --- /dev/null +++ b/api/app/controllers/spree/api/v1/inventory_units_controller.rb @@ -0,0 +1,54 @@ +module Spree + module Api + module V1 + class InventoryUnitsController < Spree::Api::BaseController + before_action :prepare_event, only: :update + + def show + @inventory_unit = inventory_unit + respond_with(@inventory_unit) + end + + def update + authorize! :update, inventory_unit.order + + inventory_unit.transaction do + if inventory_unit.update_attributes(inventory_unit_params) + fire + render :show, status: 200 + else + invalid_resource!(inventory_unit) + end + end + end + + private + + def inventory_unit + @inventory_unit ||= InventoryUnit.accessible_by(current_ability, :read).find(params[:id]) + end + + def prepare_event + return unless @event = params[:fire] + + can_event = "can_#{@event}?" + + unless inventory_unit.respond_to?(can_event) && + inventory_unit.send(can_event) + render plain: { exception: "cannot transition to #{@event}" }.to_json, + status: 200 + false + end + end + + def fire + inventory_unit.send("#{@event}!") if @event + end + + def inventory_unit_params + params.require(:inventory_unit).permit(permitted_inventory_unit_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/line_items_controller.rb b/api/app/controllers/spree/api/v1/line_items_controller.rb new file mode 100644 index 00000000000..c2941d8f02f --- /dev/null +++ b/api/app/controllers/spree/api/v1/line_items_controller.rb @@ -0,0 +1,70 @@ +module Spree + module Api + module V1 + class LineItemsController < Spree::Api::BaseController + class_attribute :line_item_options + + self.line_item_options = [] + + def new; end + + def create + variant = Spree::Variant.find(params[:line_item][:variant_id]) + + @line_item = Spree::Dependencies.cart_add_item_service.constantize.call(order: order, + variant: variant, + quantity: params[:line_item][:quantity], + options: line_item_params[:options]).value + if @line_item.errors.empty? + respond_with(@line_item, status: 201, default_template: :show) + else + invalid_resource!(@line_item) + end + end + + def update + @line_item = find_line_item + + if Spree::Dependencies.cart_update_service.constantize.call(order: @order, params: line_items_attributes).success? + @line_item.reload + respond_with(@line_item, default_template: :show) + else + invalid_resource!(@line_item) + end + end + + def destroy + @line_item = find_line_item + Spree::Dependencies.cart_remove_line_item_service.constantize.call(order: @order, line_item: @line_item) + + respond_with(@line_item, status: 204) + end + + private + + def order + @order ||= Spree::Order.includes(:line_items).find_by!(number: order_id) + authorize! :update, @order, order_token + end + + def find_line_item + id = params[:id].to_i + order.line_items.detect { |line_item| line_item.id == id } or + raise ActiveRecord::RecordNotFound + end + + def line_items_attributes + { line_items_attributes: { + id: params[:id], + quantity: params[:line_item][:quantity], + options: line_item_params[:options] || {} + } } + end + + def line_item_params + params.require(:line_item).permit(:quantity, :variant_id, options: line_item_options) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/option_types_controller.rb b/api/app/controllers/spree/api/v1/option_types_controller.rb new file mode 100644 index 00000000000..384f640f207 --- /dev/null +++ b/api/app/controllers/spree/api/v1/option_types_controller.rb @@ -0,0 +1,60 @@ +module Spree + module Api + module V1 + class OptionTypesController < Spree::Api::BaseController + def index + @option_types = if params[:ids] + Spree::OptionType. + includes(:option_values). + accessible_by(current_ability, :read). + where(id: params[:ids].split(',')) + else + Spree::OptionType. + includes(:option_values). + accessible_by(current_ability, :read). + load.ransack(params[:q]).result + end + respond_with(@option_types) + end + + def show + @option_type = Spree::OptionType.accessible_by(current_ability, :read).find(params[:id]) + respond_with(@option_type) + end + + def new; end + + def create + authorize! :create, Spree::OptionType + @option_type = Spree::OptionType.new(option_type_params) + if @option_type.save + render :show, status: 201 + else + invalid_resource!(@option_type) + end + end + + def update + @option_type = Spree::OptionType.accessible_by(current_ability, :update).find(params[:id]) + if @option_type.update_attributes(option_type_params) + render :show + else + invalid_resource!(@option_type) + end + end + + def destroy + @option_type = Spree::OptionType.accessible_by(current_ability, :destroy).find(params[:id]) + @option_type.destroy + render plain: nil, status: 204 + end + + private + + def option_type_params + params.require(:option_type).permit(permitted_option_type_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/option_values_controller.rb b/api/app/controllers/spree/api/v1/option_values_controller.rb new file mode 100644 index 00000000000..fb8e8f0ee58 --- /dev/null +++ b/api/app/controllers/spree/api/v1/option_values_controller.rb @@ -0,0 +1,62 @@ +module Spree + module Api + module V1 + class OptionValuesController < Spree::Api::BaseController + def index + @option_values = if params[:ids] + scope.where(id: params[:ids]) + else + scope.ransack(params[:q]).result.distinct + end + respond_with(@option_values) + end + + def show + @option_value = scope.find(params[:id]) + respond_with(@option_value) + end + + def new; end + + def create + authorize! :create, Spree::OptionValue + @option_value = scope.new(option_value_params) + if @option_value.save + render :show, status: 201 + else + invalid_resource!(@option_value) + end + end + + def update + @option_value = scope.accessible_by(current_ability, :update).find(params[:id]) + if @option_value.update_attributes(option_value_params) + render :show + else + invalid_resource!(@option_value) + end + end + + def destroy + @option_value = scope.accessible_by(current_ability, :destroy).find(params[:id]) + @option_value.destroy + render plain: nil, status: 204 + end + + private + + def scope + @scope ||= if params[:option_type_id] + Spree::OptionType.find(params[:option_type_id]).option_values.accessible_by(current_ability, :read) + else + Spree::OptionValue.accessible_by(current_ability, :read).load + end + end + + def option_value_params + params.require(:option_value).permit(permitted_option_value_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/orders_controller.rb b/api/app/controllers/spree/api/v1/orders_controller.rb new file mode 100644 index 00000000000..88d7e971ef5 --- /dev/null +++ b/api/app/controllers/spree/api/v1/orders_controller.rb @@ -0,0 +1,156 @@ +module Spree + module Api + module V1 + class OrdersController < Spree::Api::BaseController + skip_before_action :authenticate_user, only: :apply_coupon_code + + before_action :find_order, except: [:create, :mine, :current, :index, :update, :remove_coupon_code] + + # Dynamically defines our stores checkout steps to ensure we check authorization on each step. + Order.checkout_steps.keys.each do |step| + define_method step do + find_order + authorize! :update, @order, params[:token] + end + end + + def cancel + authorize! :update, @order, params[:token] + @order.canceled_by(current_api_user) + respond_with(@order, default_template: :show) + end + + def approve + authorize! :approve, @order, params[:token] + @order.approved_by(current_api_user) + respond_with(@order, default_template: :show) + end + + def create + authorize! :create, Spree::Order + if can?(:admin, Spree::Order) + order_user = if @current_user_roles.include?('admin') && order_params[:user_id] + Spree.user_class.find(order_params[:user_id]) + else + current_api_user + end + + import_params = if @current_user_roles.include?('admin') + params[:order].present? ? params[:order].permit! : {} + else + order_params + end + + @order = Spree::Core::Importer::Order.import(order_user, import_params) + + respond_with(@order, default_template: :show, status: 201) + else + @order = Spree::Order.create!(user: current_api_user, store: current_store) + if Cart::Update.call(order: @order, params: order_params).success? + respond_with(@order, default_template: :show, status: 201) + else + invalid_resource!(@order) + end + end + end + + def empty + authorize! :update, @order, order_token + @order.empty! + render plain: nil, status: 204 + end + + def index + authorize! :index, Order + @orders = Order.ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@orders) + end + + def show + authorize! :show, @order, order_token + respond_with(@order) + end + + def update + find_order(true) + authorize! :update, @order, order_token + + if Cart::Update.call(order: @order, params: order_params).success? + user_id = params[:order][:user_id] + if current_api_user.has_spree_role?('admin') && user_id + @order.associate_user!(Spree.user_class.find(user_id)) + end + respond_with(@order, default_template: :show) + else + invalid_resource!(@order) + end + end + + def current + @order = find_current_order + if @order + respond_with(@order, default_template: :show, locals: { root_object: @order }) + else + head :no_content + end + end + + def mine + if current_api_user.persisted? + @orders = current_api_user.orders.reverse_chronological.ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + else + render 'spree/api/errors/unauthorized', status: :unauthorized + end + end + + def apply_coupon_code + find_order + authorize! :update, @order, order_token + @order.coupon_code = params[:coupon_code] + @handler = PromotionHandler::Coupon.new(@order).apply + status = @handler.successful? ? 200 : 422 + render 'spree/api/v1/promotions/handler', status: status + end + + def remove_coupon_code + find_order(true) + authorize! :update, @order, order_token + @handler = Spree::PromotionHandler::Coupon.new(@order).remove(params[:coupon_code]) + status = @handler.successful? ? 200 : 404 + render 'spree/api/v1/promotions/handler', status: status + end + + private + + def order_params + if params[:order] + normalize_params + params.require(:order).permit(permitted_order_attributes) + else + {} + end + end + + def normalize_params + params[:order][:payments_attributes] = params[:order].delete(:payments) if params[:order][:payments] + params[:order][:shipments_attributes] = params[:order].delete(:shipments) if params[:order][:shipments] + params[:order][:line_items_attributes] = params[:order].delete(:line_items) if params[:order][:line_items] + params[:order][:ship_address_attributes] = params[:order].delete(:ship_address) if params[:order][:ship_address] + params[:order][:bill_address_attributes] = params[:order].delete(:bill_address) if params[:order][:bill_address] + end + + def find_order(lock = false) + @order = Spree::Order.lock(lock).find_by!(number: params[:id]) + end + + def find_current_order + current_api_user ? current_api_user.orders.incomplete.order(:created_at).last : nil + end + + def order_id + super || params[:id] + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/payments_controller.rb b/api/app/controllers/spree/api/v1/payments_controller.rb new file mode 100644 index 00000000000..6106de94fe5 --- /dev/null +++ b/api/app/controllers/spree/api/v1/payments_controller.rb @@ -0,0 +1,82 @@ +module Spree + module Api + module V1 + class PaymentsController < Spree::Api::BaseController + before_action :find_order + before_action :find_payment, only: [:update, :show, :authorize, :purchase, :capture, :void] + + def index + @payments = @order.payments.ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@payments) + end + + def new + @payment_methods = Spree::PaymentMethod.available + respond_with(@payment_methods) + end + + def create + @order.validate_payments_attributes([payment_params]) + @payment = @order.payments.build(payment_params) + if @payment.save + respond_with(@payment, status: 201, default_template: :show) + else + invalid_resource!(@payment) + end + end + + def update + authorize! params[:action], @payment + if !@payment.editable? + render 'update_forbidden', status: 403 + elsif @payment.update_attributes(payment_params) + respond_with(@payment, default_template: :show) + else + invalid_resource!(@payment) + end + end + + def show + respond_with(@payment) + end + + def authorize + perform_payment_action(:authorize) + end + + def capture + perform_payment_action(:capture) + end + + def purchase + perform_payment_action(:purchase) + end + + def void + perform_payment_action(:void_transaction) + end + + private + + def find_order + @order = Spree::Order.find_by!(number: order_id) + authorize! :read, @order, order_token + end + + def find_payment + @payment = @order.payments.find_by!(number: params[:id]) + end + + def perform_payment_action(action, *args) + authorize! action, Payment + @payment.send("#{action}!", *args) + respond_with(@payment, default_template: :show) + end + + def payment_params + params.require(:payment).permit(permitted_payment_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/product_properties_controller.rb b/api/app/controllers/spree/api/v1/product_properties_controller.rb new file mode 100644 index 00000000000..6f012e96da1 --- /dev/null +++ b/api/app/controllers/spree/api/v1/product_properties_controller.rb @@ -0,0 +1,73 @@ +module Spree + module Api + module V1 + class ProductPropertiesController < Spree::Api::BaseController + before_action :find_product, :authorize_product! + before_action :product_property, only: [:show, :update, :destroy] + + def index + @product_properties = @product.product_properties.accessible_by(current_ability, :read). + ransack(params[:q]).result. + page(params[:page]).per(params[:per_page]) + respond_with(@product_properties) + end + + def show + respond_with(@product_property) + end + + def new; end + + def create + authorize! :create, ProductProperty + @product_property = @product.product_properties.new(product_property_params) + if @product_property.save + respond_with(@product_property, status: 201, default_template: :show) + else + invalid_resource!(@product_property) + end + end + + def update + authorize! :update, @product_property + + if @product_property.update_attributes(product_property_params) + respond_with(@product_property, status: 200, default_template: :show) + else + invalid_resource!(@product_property) + end + end + + def destroy + authorize! :destroy, @product_property + @product_property.destroy + respond_with(@product_property, status: 204) + end + + private + + def find_product + super(params[:product_id]) + end + + def authorize_product! + authorize! :read, @product + end + + def product_property + if @product + @product_property ||= @product.product_properties.find_by(id: params[:id]) + @product_property ||= @product.product_properties.includes(:property).where(spree_properties: { name: params[:id] }).first + raise ActiveRecord::RecordNotFound unless @product_property + + authorize! :read, @product_property + end + end + + def product_property_params + params.require(:product_property).permit(permitted_product_properties_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/products_controller.rb b/api/app/controllers/spree/api/v1/products_controller.rb new file mode 100644 index 00000000000..cdee655e9a1 --- /dev/null +++ b/api/app/controllers/spree/api/v1/products_controller.rb @@ -0,0 +1,131 @@ +module Spree + module Api + module V1 + class ProductsController < Spree::Api::BaseController + before_action :find_product, only: [:update, :show, :destroy] + + def index + @products = if params[:ids] + product_scope.where(id: params[:ids].split(',').flatten) + else + product_scope.ransack(params[:q]).result + end + + @products = @products.distinct.page(params[:page]).per(params[:per_page]) + expires_in 15.minutes, public: true + headers['Surrogate-Control'] = "max-age=#{15.minutes}" + respond_with(@products) + end + + def show + expires_in 15.minutes, public: true + headers['Surrogate-Control'] = "max-age=#{15.minutes}" + headers['Surrogate-Key'] = 'product_id=1' + respond_with(@product) + end + + # Takes besides the products attributes either an array of variants or + # an array of option types. + # + # By submitting an array of variants the option types will be created + # using the *name* key in options hash. e.g + # + # product: { + # ... + # variants: { + # price: 19.99, + # sku: "hey_you", + # options: [ + # { name: "size", value: "small" }, + # { name: "color", value: "black" } + # ] + # } + # } + # + # Or just pass in the option types hash: + # + # product: { + # ... + # option_types: ['size', 'color'] + # } + # + # By passing the shipping category name you can fetch or create that + # shipping category on the fly. e.g. + # + # product: { + # ... + # shipping_category: "Free Shipping Items" + # } + # + def new; end + + def create + authorize! :create, Product + params[:product][:available_on] ||= Time.current + set_up_shipping_category + + options = { variants_attrs: variants_params, options_attrs: option_types_params } + @product = Core::Importer::Product.new(nil, product_params, options).create + + if @product.persisted? + respond_with(@product, status: 201, default_template: :show) + else + invalid_resource!(@product) + end + end + + def update + authorize! :update, @product + + options = { variants_attrs: variants_params, options_attrs: option_types_params } + @product = Core::Importer::Product.new(@product, product_params, options).update + + if @product.errors.empty? + respond_with(@product.reload, status: 200, default_template: :show) + else + invalid_resource!(@product) + end + end + + def destroy + authorize! :destroy, @product + @product.destroy + respond_with(@product, status: 204) + end + + private + + def product_params + params.require(:product).permit(permitted_product_attributes) + end + + def variants_params + variants_key = if params[:product].key? :variants + :variants + else + :variants_attributes + end + + params.require(:product).permit( + variants_key => [permitted_variant_attributes, :id] + ).delete(variants_key) || [] + end + + def option_types_params + params[:product].fetch(:option_types, []) + end + + def find_product + super(params[:id]) + end + + def set_up_shipping_category + if shipping_category = params[:product].delete(:shipping_category) + id = ShippingCategory.find_or_create_by(name: shipping_category).id + params[:product][:shipping_category_id] = id + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/promotions_controller.rb b/api/app/controllers/spree/api/v1/promotions_controller.rb new file mode 100644 index 00000000000..92eea86979e --- /dev/null +++ b/api/app/controllers/spree/api/v1/promotions_controller.rb @@ -0,0 +1,30 @@ +module Spree + module Api + module V1 + class PromotionsController < Spree::Api::BaseController + before_action :requires_admin + before_action :load_promotion + + def show + if @promotion + respond_with(@promotion, default_template: :show) + else + raise ActiveRecord::RecordNotFound + end + end + + private + + def requires_admin + return if @current_user_roles.include?('admin') + + unauthorized and return + end + + def load_promotion + @promotion = Spree::Promotion.find_by(id: params[:id]) || Spree::Promotion.with_coupon_code(params[:id]) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/properties_controller.rb b/api/app/controllers/spree/api/v1/properties_controller.rb new file mode 100644 index 00000000000..cb2c9240fa2 --- /dev/null +++ b/api/app/controllers/spree/api/v1/properties_controller.rb @@ -0,0 +1,70 @@ +module Spree + module Api + module V1 + class PropertiesController < Spree::Api::BaseController + before_action :find_property, only: [:show, :update, :destroy] + + def index + @properties = Spree::Property.accessible_by(current_ability, :read) + + @properties = if params[:ids] + @properties.where(id: params[:ids].split(',').flatten) + else + @properties.ransack(params[:q]).result + end + + @properties = @properties.page(params[:page]).per(params[:per_page]) + respond_with(@properties) + end + + def show + respond_with(@property) + end + + def new; end + + def create + authorize! :create, Property + @property = Spree::Property.new(property_params) + if @property.save + respond_with(@property, status: 201, default_template: :show) + else + invalid_resource!(@property) + end + end + + def update + if @property + authorize! :update, @property + @property.update_attributes(property_params) + respond_with(@property, status: 200, default_template: :show) + else + invalid_resource!(@property) + end + end + + def destroy + if @property + authorize! :destroy, @property + @property.destroy + respond_with(@property, status: 204) + else + invalid_resource!(@property) + end + end + + private + + def find_property + @property = Spree::Property.accessible_by(current_ability, :read).find(params[:id]) + rescue ActiveRecord::RecordNotFound + @property = Spree::Property.accessible_by(current_ability, :read).find_by!(name: params[:id]) + end + + def property_params + params.require(:property).permit(permitted_property_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/reimbursements_controller.rb b/api/app/controllers/spree/api/v1/reimbursements_controller.rb new file mode 100644 index 00000000000..09685edd189 --- /dev/null +++ b/api/app/controllers/spree/api/v1/reimbursements_controller.rb @@ -0,0 +1,25 @@ +module Spree + module Api + module V1 + class ReimbursementsController < Spree::Api::BaseController + def index + collection(Spree::Reimbursement) + respond_with(@collection) + end + + private + + def collection(resource) + return @collection if @collection.present? + + params[:q] ||= {} + + @collection = resource.all + # @search needs to be defined as this is passed to search_form_for + @search = @collection.ransack(params[:q]) + @collection = @search.result.order(created_at: :desc).page(params[:page]).per(params[:per_page]) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/return_authorizations_controller.rb b/api/app/controllers/spree/api/v1/return_authorizations_controller.rb new file mode 100644 index 00000000000..21cc75d808a --- /dev/null +++ b/api/app/controllers/spree/api/v1/return_authorizations_controller.rb @@ -0,0 +1,70 @@ +module Spree + module Api + module V1 + class ReturnAuthorizationsController < Spree::Api::BaseController + def create + authorize! :create, ReturnAuthorization + @return_authorization = order.return_authorizations.build(return_authorization_params) + if @return_authorization.save + respond_with(@return_authorization, status: 201, default_template: :show) + else + invalid_resource!(@return_authorization) + end + end + + def destroy + @return_authorization = order.return_authorizations.accessible_by(current_ability, :destroy).find(params[:id]) + @return_authorization.destroy + respond_with(@return_authorization, status: 204) + end + + def index + authorize! :admin, ReturnAuthorization + @return_authorizations = order.return_authorizations.accessible_by(current_ability, :read). + ransack(params[:q]).result. + page(params[:page]).per(params[:per_page]) + respond_with(@return_authorizations) + end + + def new + authorize! :admin, ReturnAuthorization + end + + def show + authorize! :admin, ReturnAuthorization + @return_authorization = order.return_authorizations.accessible_by(current_ability, :read).find(params[:id]) + respond_with(@return_authorization) + end + + def update + @return_authorization = order.return_authorizations.accessible_by(current_ability, :update).find(params[:id]) + if @return_authorization.update_attributes(return_authorization_params) + respond_with(@return_authorization, default_template: :show) + else + invalid_resource!(@return_authorization) + end + end + + def cancel + @return_authorization = order.return_authorizations.accessible_by(current_ability, :update).find(params[:id]) + if @return_authorization.cancel + respond_with @return_authorization, default_template: :show + else + invalid_resource!(@return_authorization) + end + end + + private + + def order + @order ||= Spree::Order.find_by!(number: order_id) + authorize! :read, @order + end + + def return_authorization_params + params.require(:return_authorization).permit(permitted_return_authorization_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/shipments_controller.rb b/api/app/controllers/spree/api/v1/shipments_controller.rb new file mode 100644 index 00000000000..15542933ce6 --- /dev/null +++ b/api/app/controllers/spree/api/v1/shipments_controller.rb @@ -0,0 +1,186 @@ +module Spree + module Api + module V1 + class ShipmentsController < Spree::Api::BaseController + before_action :find_and_update_shipment, only: [:ship, :ready, :add, :remove] + before_action :load_transfer_params, only: [:transfer_to_location, :transfer_to_shipment] + + def mine + if current_api_user.persisted? + @shipments = Spree::Shipment. + reverse_chronological. + joins(:order). + where(spree_orders: { user_id: current_api_user.id }). + includes(mine_includes). + ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + else + render 'spree/api/errors/unauthorized', status: :unauthorized + end + end + + def create + @order = Spree::Order.find_by!(number: params.fetch(:shipment).fetch(:order_id)) + authorize! :read, @order + authorize! :create, Shipment + quantity = params[:quantity].to_i + @shipment = @order.shipments.create(stock_location_id: params.fetch(:stock_location_id)) + + @line_item = Spree::Dependencies.cart_add_item_service.constantize.call(order: @order, + variant: variant, + quantity: quantity, + options: { shipment: @shipment }).value + + respond_with(@shipment.reload, default_template: :show) + end + + def update + @shipment = Spree::Shipment.accessible_by(current_ability, :update).readonly(false).find_by!(number: params[:id]) + @shipment.update_attributes_and_order(shipment_params) + + respond_with(@shipment.reload, default_template: :show) + end + + def ready + unless @shipment.ready? + if @shipment.can_ready? + @shipment.ready! + else + render 'spree/api/v1/shipments/cannot_ready_shipment', status: 422 and return + end + end + respond_with(@shipment, default_template: :show) + end + + def ship + @shipment.ship! unless @shipment.shipped? + respond_with(@shipment, default_template: :show) + end + + def add + quantity = params[:quantity].to_i + + Spree::Dependencies.cart_add_item_service.constantize.call(order: @shipment.order, + variant: variant, + quantity: quantity, + options: { shipment: @shipment }) + + respond_with(@shipment, default_template: :show) + end + + def remove + quantity = if params.key?(:quantity) + params[:quantity].to_i + else + @shipment.inventory_units_for(variant).sum(:quantity) + end + + Spree::Dependencies.cart_remove_item_service.constantize.call(order: @shipment.order, + variant: variant, + quantity: quantity, + options: { shipment: @shipment }) + + if @shipment.inventory_units.any? + @shipment.reload + else + @shipment.destroy! + end + + respond_with(@shipment, default_template: :show) + end + + def transfer_to_location + @stock_location = Spree::StockLocation.find(params[:stock_location_id]) + + unless @quantity > 0 + unprocessable_entity("#{Spree.t(:shipment_transfer_errors_occured, scope: 'api')} \n #{Spree.t(:negative_quantity, scope: 'api')}") + return + end + + @original_shipment.transfer_to_location(@variant, @quantity, @stock_location) + render json: { success: true, message: Spree.t(:shipment_transfer_success) }, status: 201 + end + + def transfer_to_shipment + @target_shipment = Spree::Shipment.find_by!(number: params[:target_shipment_number]) + + error = + if @quantity < 0 && @target_shipment == @original_shipment + "#{Spree.t(:negative_quantity, scope: 'api')}, \n#{Spree.t('wrong_shipment_target', scope: 'api')}" + elsif @target_shipment == @original_shipment + Spree.t(:wrong_shipment_target, scope: 'api') + elsif @quantity < 0 + Spree.t(:negative_quantity, scope: 'api') + end + + if error + unprocessable_entity("#{Spree.t(:shipment_transfer_errors_occured, scope: 'api')} \n#{error}") + else + @original_shipment.transfer_to_shipment(@variant, @quantity, @target_shipment) + render json: { success: true, message: Spree.t(:shipment_transfer_success) }, status: 201 + end + end + + private + + def load_transfer_params + @original_shipment = Spree::Shipment.find_by!(number: params[:original_shipment_number]) + @variant = Spree::Variant.find(params[:variant_id]) + @quantity = params[:quantity].to_i + authorize! :read, @original_shipment + authorize! :create, Shipment + end + + def find_and_update_shipment + @shipment = Spree::Shipment.accessible_by(current_ability, :update).readonly(false).find_by!(number: params[:id]) + @shipment.update_attributes(shipment_params) + @shipment.reload + end + + def shipment_params + if params[:shipment] && !params[:shipment].empty? + params.require(:shipment).permit(permitted_shipment_attributes) + else + {} + end + end + + def variant + @variant ||= Spree::Variant.unscoped.find(params.fetch(:variant_id)) + end + + def mine_includes + { + order: { + bill_address: { + state: {}, + country: {} + }, + ship_address: { + state: {}, + country: {} + }, + adjustments: {}, + payments: { + order: {}, + payment_method: {} + } + }, + inventory_units: { + line_item: { + product: {}, + variant: {} + }, + variant: { + product: {}, + default_price: {}, + option_values: { + option_type: {} + } + } + } + } + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/states_controller.rb b/api/app/controllers/spree/api/v1/states_controller.rb new file mode 100644 index 00000000000..b006335d516 --- /dev/null +++ b/api/app/controllers/spree/api/v1/states_controller.rb @@ -0,0 +1,36 @@ +module Spree + module Api + module V1 + class StatesController < Spree::Api::BaseController + skip_before_action :authenticate_user + + def index + @states = scope.ransack(params[:q]).result.includes(:country) + + if params[:page] || params[:per_page] + @states = @states.page(params[:page]).per(params[:per_page]) + end + + state = @states.last + respond_with(@states) if stale?(state) + end + + def show + @state = scope.find(params[:id]) + respond_with(@state) + end + + private + + def scope + if params[:country_id] + @country = Country.accessible_by(current_ability, :read).find(params[:country_id]) + @country.states.accessible_by(current_ability, :read).order('name ASC') + else + State.accessible_by(current_ability, :read).order('name ASC') + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/stock_items_controller.rb b/api/app/controllers/spree/api/v1/stock_items_controller.rb new file mode 100644 index 00000000000..355fabf4500 --- /dev/null +++ b/api/app/controllers/spree/api/v1/stock_items_controller.rb @@ -0,0 +1,82 @@ +module Spree + module Api + module V1 + class StockItemsController < Spree::Api::BaseController + before_action :stock_location, except: [:update, :destroy] + + def index + @stock_items = scope.ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@stock_items) + end + + def show + @stock_item = scope.find(params[:id]) + respond_with(@stock_item) + end + + def create + authorize! :create, StockItem + + count_on_hand = 0 + if params[:stock_item].key?(:count_on_hand) + count_on_hand = params[:stock_item][:count_on_hand].to_i + end + + @stock_item = scope.new(stock_item_params) + if @stock_item.save + @stock_item.adjust_count_on_hand(count_on_hand) + respond_with(@stock_item, status: 201, default_template: :show) + else + invalid_resource!(@stock_item) + end + end + + def update + @stock_item = StockItem.accessible_by(current_ability, :update).find(params[:id]) + + if params[:stock_item].key?(:backorderable) + @stock_item.backorderable = params[:stock_item][:backorderable] + @stock_item.save + end + + count_on_hand = 0 + if params[:stock_item].key?(:count_on_hand) + count_on_hand = params[:stock_item][:count_on_hand].to_i + params[:stock_item].delete(:count_on_hand) + end + + updated = params[:stock_item][:force] ? @stock_item.set_count_on_hand(count_on_hand) + : @stock_item.adjust_count_on_hand(count_on_hand) + + if updated + respond_with(@stock_item, status: 200, default_template: :show) + else + invalid_resource!(@stock_item) + end + end + + def destroy + @stock_item = StockItem.accessible_by(current_ability, :destroy).find(params[:id]) + @stock_item.destroy + respond_with(@stock_item, status: 204) + end + + private + + def stock_location + render 'spree/api/v1/shared/stock_location_required', status: 422 and return unless params[:stock_location_id] + @stock_location ||= StockLocation.accessible_by(current_ability, :read).find(params[:stock_location_id]) + end + + def scope + includes = { variant: [{ option_values: :option_type }, :product] } + @stock_location.stock_items.accessible_by(current_ability, :read).includes(includes) + end + + def stock_item_params + params.require(:stock_item).permit(permitted_stock_item_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/stock_locations_controller.rb b/api/app/controllers/spree/api/v1/stock_locations_controller.rb new file mode 100644 index 00000000000..18481955d88 --- /dev/null +++ b/api/app/controllers/spree/api/v1/stock_locations_controller.rb @@ -0,0 +1,52 @@ +module Spree + module Api + module V1 + class StockLocationsController < Spree::Api::BaseController + def index + authorize! :read, StockLocation + @stock_locations = StockLocation.accessible_by(current_ability, :read).order('name ASC').ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@stock_locations) + end + + def show + respond_with(stock_location) + end + + def create + authorize! :create, StockLocation + @stock_location = StockLocation.new(stock_location_params) + if @stock_location.save + respond_with(@stock_location, status: 201, default_template: :show) + else + invalid_resource!(@stock_location) + end + end + + def update + authorize! :update, stock_location + if stock_location.update_attributes(stock_location_params) + respond_with(stock_location, status: 200, default_template: :show) + else + invalid_resource!(stock_location) + end + end + + def destroy + authorize! :destroy, stock_location + stock_location.destroy + respond_with(stock_location, status: 204) + end + + private + + def stock_location + @stock_location ||= StockLocation.accessible_by(current_ability, :read).find(params[:id]) + end + + def stock_location_params + params.require(:stock_location).permit(permitted_stock_location_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/stock_movements_controller.rb b/api/app/controllers/spree/api/v1/stock_movements_controller.rb new file mode 100644 index 00000000000..c60efff28be --- /dev/null +++ b/api/app/controllers/spree/api/v1/stock_movements_controller.rb @@ -0,0 +1,45 @@ +module Spree + module Api + module V1 + class StockMovementsController < Spree::Api::BaseController + before_action :stock_location, except: [:update, :destroy] + + def index + authorize! :read, StockMovement + @stock_movements = scope.ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@stock_movements) + end + + def show + @stock_movement = scope.find(params[:id]) + respond_with(@stock_movement) + end + + def create + authorize! :create, StockMovement + @stock_movement = scope.new(stock_movement_params) + if @stock_movement.save + respond_with(@stock_movement, status: 201, default_template: :show) + else + invalid_resource!(@stock_movement) + end + end + + private + + def stock_location + render 'spree/api/v1/shared/stock_location_required', status: 422 and return unless params[:stock_location_id] + @stock_location ||= StockLocation.accessible_by(current_ability, :read).find(params[:stock_location_id]) + end + + def scope + @stock_location.stock_movements.accessible_by(current_ability, :read) + end + + def stock_movement_params + params.require(:stock_movement).permit(permitted_stock_movement_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/stores_controller.rb b/api/app/controllers/spree/api/v1/stores_controller.rb new file mode 100644 index 00000000000..4b5f36d3426 --- /dev/null +++ b/api/app/controllers/spree/api/v1/stores_controller.rb @@ -0,0 +1,56 @@ +module Spree + module Api + module V1 + class StoresController < Spree::Api::BaseController + before_action :get_store, except: [:index, :create] + + def index + authorize! :read, Store + @stores = Store.accessible_by(current_ability, :read).all + respond_with(@stores) + end + + def create + authorize! :create, Store + @store = Store.new(store_params) + @store.code = params[:store][:code] + if @store.save + respond_with(@store, status: 201, default_template: :show) + else + invalid_resource!(@store) + end + end + + def update + authorize! :update, @store + if @store.update_attributes(store_params) + respond_with(@store, status: 200, default_template: :show) + else + invalid_resource!(@store) + end + end + + def show + authorize! :read, @store + respond_with(@store) + end + + def destroy + authorize! :destroy, @store + @store.destroy + respond_with(@store, status: 204) + end + + private + + def get_store + @store = Store.find(params[:id]) + end + + def store_params + params.require(:store).permit(permitted_store_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/tags_controller.rb b/api/app/controllers/spree/api/v1/tags_controller.rb new file mode 100644 index 00000000000..04ac12921dd --- /dev/null +++ b/api/app/controllers/spree/api/v1/tags_controller.rb @@ -0,0 +1,28 @@ +module Spree + module Api + module V1 + class TagsController < Spree::Api::BaseController + def index + @tags = + if params[:ids] + Tag.where(id: params[:ids].split(',').flatten) + else + Tag.ransack(params[:q]).result + end + + @tags = @tags.page(params[:page]).per(params[:per_page]) + + expires_in 15.minutes, public: true + headers['Surrogate-Control'] = "max-age=#{15.minutes}" + respond_with(@tags) + end + + private + + def tags_params + params.require(:tag).permit(permitted_tags_attributes) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/taxonomies_controller.rb b/api/app/controllers/spree/api/v1/taxonomies_controller.rb new file mode 100644 index 00000000000..8f86482570a --- /dev/null +++ b/api/app/controllers/spree/api/v1/taxonomies_controller.rb @@ -0,0 +1,67 @@ +module Spree + module Api + module V1 + class TaxonomiesController < Spree::Api::BaseController + def index + respond_with(taxonomies) + end + + def show + respond_with(taxonomy) + end + + # Because JSTree wants parameters in a *slightly* different format + def jstree + show + end + + def new; end + + def create + authorize! :create, Taxonomy + @taxonomy = Taxonomy.new(taxonomy_params) + if @taxonomy.save + respond_with(@taxonomy, status: 201, default_template: :show) + else + invalid_resource!(@taxonomy) + end + end + + def update + authorize! :update, taxonomy + if taxonomy.update_attributes(taxonomy_params) + respond_with(taxonomy, status: 200, default_template: :show) + else + invalid_resource!(taxonomy) + end + end + + def destroy + authorize! :destroy, taxonomy + taxonomy.destroy + respond_with(taxonomy, status: 204) + end + + private + + def taxonomies + @taxonomies = Taxonomy.accessible_by(current_ability, :read).order('name').includes(root: :children). + ransack(params[:q]).result. + page(params[:page]).per(params[:per_page]) + end + + def taxonomy + @taxonomy ||= Taxonomy.accessible_by(current_ability, :read).find(params[:id]) + end + + def taxonomy_params + if params[:taxonomy] && !params[:taxonomy].empty? + params.require(:taxonomy).permit(permitted_taxonomy_attributes) + else + {} + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/taxons_controller.rb b/api/app/controllers/spree/api/v1/taxons_controller.rb new file mode 100644 index 00000000000..1d359fe101d --- /dev/null +++ b/api/app/controllers/spree/api/v1/taxons_controller.rb @@ -0,0 +1,95 @@ +module Spree + module Api + module V1 + class TaxonsController < Spree::Api::BaseController + def index + @taxons = if taxonomy + taxonomy.root.children + elsif params[:ids] + Spree::Taxon.includes(:children).accessible_by(current_ability, :read).where(id: params[:ids].split(',')) + else + Spree::Taxon.includes(:children).accessible_by(current_ability, :read).order(:taxonomy_id, :lft) + end + @taxons = @taxons.ransack(params[:q]).result + @taxons = @taxons.page(params[:page]).per(params[:per_page]) + respond_with(@taxons) + end + + def show + @taxon = taxon + respond_with(@taxon) + end + + def jstree + show + end + + def new; end + + def create + authorize! :create, Taxon + @taxon = Spree::Taxon.new(taxon_params) + @taxon.taxonomy_id = params[:taxonomy_id] + taxonomy = Spree::Taxonomy.find_by(id: params[:taxonomy_id]) + + if taxonomy.nil? + @taxon.errors.add(:taxonomy_id, I18n.t('spree.api.invalid_taxonomy_id')) + invalid_resource!(@taxon) and return + end + + @taxon.parent_id = taxonomy.root.id unless params[:taxon][:parent_id] + + if @taxon.save + respond_with(@taxon, status: 201, default_template: :show) + else + invalid_resource!(@taxon) + end + end + + def update + authorize! :update, taxon + if taxon.update_attributes(taxon_params) + respond_with(taxon, status: 200, default_template: :show) + else + invalid_resource!(taxon) + end + end + + def destroy + authorize! :destroy, taxon + taxon.destroy + respond_with(taxon, status: 204) + end + + def products + # Returns the products sorted by their position with the classification + # Products#index does not do the sorting. + taxon = Spree::Taxon.find(params[:id]) + @products = taxon.products.ransack(params[:q]).result + @products = @products.page(params[:page]).per(params[:per_page] || 500) + render 'spree/api/v1/products/index' + end + + private + + def taxonomy + if params[:taxonomy_id].present? + @taxonomy ||= Spree::Taxonomy.accessible_by(current_ability, :read).find(params[:taxonomy_id]) + end + end + + def taxon + @taxon ||= taxonomy.taxons.accessible_by(current_ability, :read).find(params[:id]) + end + + def taxon_params + if params[:taxon] && !params[:taxon].empty? + params.require(:taxon).permit(permitted_taxon_attributes) + else + {} + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/users_controller.rb b/api/app/controllers/spree/api/v1/users_controller.rb new file mode 100644 index 00000000000..78e60148042 --- /dev/null +++ b/api/app/controllers/spree/api/v1/users_controller.rb @@ -0,0 +1,67 @@ +module Spree + module Api + module V1 + class UsersController < Spree::Api::BaseController + rescue_from Spree::Core::DestroyWithOrdersError, with: :error_during_processing + + def index + @users = Spree.user_class.accessible_by(current_ability, :read) + + @users = if params[:ids] + @users.ransack(id_in: params[:ids].split(',')) + else + @users.ransack(params[:q]) + end + + @users = @users.result.page(params[:page]).per(params[:per_page]) + expires_in 15.minutes, public: true + headers['Surrogate-Control'] = "max-age=#{15.minutes}" + respond_with(@users) + end + + def show + respond_with(user) + end + + def new; end + + def create + authorize! :create, Spree.user_class + @user = Spree.user_class.new(user_params) + if @user.save + respond_with(@user, status: 201, default_template: :show) + else + invalid_resource!(@user) + end + end + + def update + authorize! :update, user + if user.update_attributes(user_params) + respond_with(user, status: 200, default_template: :show) + else + invalid_resource!(user) + end + end + + def destroy + authorize! :destroy, user + user.destroy + respond_with(user, status: 204) + end + + private + + def user + @user ||= Spree.user_class.accessible_by(current_ability, :read).find(params[:id]) + end + + def user_params + params.require(:user).permit(permitted_user_attributes | + [bill_address_attributes: permitted_address_attributes, + ship_address_attributes: permitted_address_attributes]) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/variants_controller.rb b/api/app/controllers/spree/api/v1/variants_controller.rb new file mode 100644 index 00000000000..ff69928599d --- /dev/null +++ b/api/app/controllers/spree/api/v1/variants_controller.rb @@ -0,0 +1,81 @@ +module Spree + module Api + module V1 + class VariantsController < Spree::Api::BaseController + before_action :product + + def create + authorize! :create, Variant + @variant = scope.new(variant_params) + if @variant.save + respond_with(@variant, status: 201, default_template: :show) + else + invalid_resource!(@variant) + end + end + + def destroy + @variant = scope.accessible_by(current_ability, :destroy).find(params[:id]) + @variant.destroy + respond_with(@variant, status: 204) + end + + # The lazyloaded associations here are pretty much attached to which nodes + # we render on the view so we better update it any time a node is included + # or removed from the views. + def index + @variants = scope.includes(*variant_includes).for_currency_and_available_price_amount. + ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@variants) + end + + def new; end + + def show + @variant = scope.includes(*variant_includes).find(params[:id]) + respond_with(@variant) + end + + def update + @variant = scope.accessible_by(current_ability, :update).find(params[:id]) + if @variant.update_attributes(variant_params) + respond_with(@variant, status: 200, default_template: :show) + else + invalid_resource!(@product) + end + end + + private + + def product + if params[:product_id] + @product ||= Spree::Product.accessible_by(current_ability, :read). + friendly.find(params[:product_id]) + end + end + + def scope + variants = if @product + @product.variants_including_master + else + Variant + end + + if current_ability.can?(:manage, Variant) && params[:show_deleted] + variants = variants.with_deleted + end + + variants.eligible.accessible_by(current_ability, :read) + end + + def variant_params + params.require(:variant).permit(permitted_variant_attributes) + end + + def variant_includes + [{ option_values: :option_type }, :product, :default_price, :images, { stock_items: :stock_location }] + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v1/zones_controller.rb b/api/app/controllers/spree/api/v1/zones_controller.rb new file mode 100644 index 00000000000..4e1eade91bf --- /dev/null +++ b/api/app/controllers/spree/api/v1/zones_controller.rb @@ -0,0 +1,55 @@ +module Spree + module Api + module V1 + class ZonesController < Spree::Api::BaseController + def create + authorize! :create, Zone + @zone = Spree::Zone.new(zone_params) + if @zone.save + respond_with(@zone, status: 201, default_template: :show) + else + invalid_resource!(@zone) + end + end + + def destroy + authorize! :destroy, zone + zone.destroy + respond_with(zone, status: 204) + end + + def index + @zones = Zone.accessible_by(current_ability, :read).order('name ASC').ransack(params[:q]).result.page(params[:page]).per(params[:per_page]) + respond_with(@zones) + end + + def show + respond_with(zone) + end + + def update + authorize! :update, zone + if zone.update_attributes(zone_params) + respond_with(zone, status: 200, default_template: :show) + else + invalid_resource!(zone) + end + end + + private + + def zone_params + attrs = params.require(:zone).permit! + if attrs[:zone_members] + attrs[:zone_members_attributes] = attrs.delete(:zone_members) + end + attrs + end + + def zone + @zone ||= Spree::Zone.accessible_by(current_ability, :read).find(params[:id]) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/base_controller.rb b/api/app/controllers/spree/api/v2/base_controller.rb new file mode 100644 index 00000000000..8e3d0c15c57 --- /dev/null +++ b/api/app/controllers/spree/api/v2/base_controller.rb @@ -0,0 +1,98 @@ +module Spree + module Api + module V2 + class BaseController < ActionController::API + include CanCan::ControllerAdditions + include Spree::Core::ControllerHelpers::StrongParameters + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + rescue_from CanCan::AccessDenied, with: :access_denied + + private + + def collection_paginator + Spree::Api::Dependencies.storefront_collection_paginator.constantize + end + + def render_serialized_payload(status = 200) + render json: yield, status: status + rescue ArgumentError => exception + render_error_payload(exception.message, 400) + end + + def render_error_payload(error, status = 422) + if error.is_a?(Struct) + render json: { error: error.to_s, errors: error.to_h }, status: status + elsif error.is_a?(String) + render json: { error: error }, status: status + end + end + + def spree_current_store + @spree_current_store ||= Spree::Store.current(request.env['SERVER_NAME']) + end + + def spree_current_user + @spree_current_user ||= Spree.user_class.find_by(id: doorkeeper_token.resource_owner_id) if doorkeeper_token + end + + def spree_authorize!(action, subject, *args) + authorize!(action, subject, *args) + end + + def require_spree_current_user + raise CanCan::AccessDenied if spree_current_user.nil? + end + + # Needs to be overriden so that we use Spree's Ability rather than anyone else's. + def current_ability + @current_ability ||= Spree::Dependencies.ability_class.constantize.new(spree_current_user) + end + + def request_includes + # if API user want's to receive only the bare-minimum + # the API will return only the main resource without any included + if params[:include]&.blank? + [] + elsif params[:include].present? + params[:include].split(',') + end + end + + def resource_includes + (request_includes || default_resource_includes).map(&:intern) + end + + # overwrite this method in your controllers to set JSON API default include value + # https://jsonapi.org/format/#fetching-includes + # eg.: + # %w[images variants] + # ['variant.images', 'line_items'] + def default_resource_includes + [] + end + + def sparse_fields + return unless params[:fields]&.respond_to?(:each) + + fields = {} + params[:fields]. + select { |_, v| v.is_a?(String) }. + each { |type, values| fields[type.intern] = values.split(',').map(&:intern) } + fields.presence + end + + def current_currency + spree_current_store.default_currency || Spree::Config[:currency] + end + + def record_not_found + render_error_payload(I18n.t(:resource_not_found, scope: 'spree.api'), 404) + end + + def access_denied(exception) + render_error_payload(exception.message, 403) + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/account/credit_cards_controller.rb b/api/app/controllers/spree/api/v2/storefront/account/credit_cards_controller.rb new file mode 100644 index 00000000000..761bdf9678b --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/account/credit_cards_controller.rb @@ -0,0 +1,59 @@ +module Spree + module Api + module V2 + module Storefront + module Account + class CreditCardsController < ::Spree::Api::V2::BaseController + before_action :require_spree_current_user + + def index + render_serialized_payload { serialize_collection(resource) } + end + + def show + render_serialized_payload { serialize_resource(resource) } + end + + private + + def resource + resource_finder.new.execute(scope: scope, params: params) + end + + def collection_serializer + Spree::Api::Dependencies.storefront_credit_card_serializer.constantize + end + + def resource_serializer + Spree::Api::Dependencies.storefront_credit_card_serializer.constantize + end + + def resource_finder + Spree::Api::Dependencies.storefront_credit_card_finder.constantize + end + + def serialize_collection(collection) + collection_serializer.new( + collection, + include: resource_includes, + fields: sparse_fields + ).serializable_hash + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + fields: sparse_fields + ).serializable_hash + end + + def scope + spree_current_user.credit_cards.accessible_by(current_ability, :read) + end + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/account/orders_controller.rb b/api/app/controllers/spree/api/v2/storefront/account/orders_controller.rb new file mode 100644 index 00000000000..e8e505924f4 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/account/orders_controller.rb @@ -0,0 +1,93 @@ +module Spree + module Api + module V2 + module Storefront + module Account + class OrdersController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::CollectionOptionsHelpers + before_action :require_spree_current_user + + def index + render_serialized_payload { serialize_collection(paginated_collection) } + end + + def show + spree_authorize! :show, resource + + render_serialized_payload { serialize_resource(resource) } + end + + private + + def paginated_collection + collection_paginator.new(sorted_collection, params).call + end + + def sorted_collection + collection_sorter.new(collection, params).call + end + + def collection + collection_finder.new(user: spree_current_user).execute + end + + def resource + resource = resource_finder.new(user: spree_current_user, number: params[:id]).execute.take + raise ActiveRecord::RecordNotFound if resource.nil? + + resource + end + + def serialize_collection(collection) + collection_serializer.new( + collection, + collection_options(collection) + ).serializable_hash + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + sparse_fields: sparse_fields + ).serializable_hash + end + + def collection_serializer + Spree::Api::Dependencies.storefront_cart_serializer.constantize + end + + def resource_serializer + Spree::Api::Dependencies.storefront_cart_serializer.constantize + end + + def collection_finder + Spree::Api::Dependencies.storefront_completed_order_finder.constantize + end + + def resource_finder + Spree::Api::Dependencies.storefront_completed_order_finder.constantize + end + + def collection_sorter + Spree::Api::Dependencies.storefront_order_sorter.constantize + end + + def collection_paginator + Spree::Api::Dependencies.storefront_collection_paginator.constantize + end + + def collection_options(collection) + { + links: collection_links(collection), + meta: collection_meta(collection), + include: resource_includes, + sparse_fields: sparse_fields + } + end + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/account_controller.rb b/api/app/controllers/spree/api/v2/storefront/account_controller.rb new file mode 100644 index 00000000000..9131f101e54 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/account_controller.rb @@ -0,0 +1,33 @@ +module Spree + module Api + module V2 + module Storefront + class AccountController < ::Spree::Api::V2::BaseController + before_action :require_spree_current_user + + def show + render_serialized_payload { serialize_resource(resource) } + end + + private + + def resource + spree_current_user + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + fields: sparse_fields + ).serializable_hash + end + + def resource_serializer + Spree::Api::Dependencies.storefront_user_serializer.constantize + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/cart_controller.rb b/api/app/controllers/spree/api/v2/storefront/cart_controller.rb new file mode 100644 index 00000000000..6257908b8b0 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/cart_controller.rb @@ -0,0 +1,139 @@ +module Spree + module Api + module V2 + module Storefront + class CartController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::Storefront::OrderConcern + before_action :ensure_order, except: :create + + def create + spree_authorize! :create, Spree::Order + + order_params = { + user: spree_current_user, + store: spree_current_store, + currency: current_currency + } + + order = spree_current_order if spree_current_order.present? + order ||= create_service.call(order_params).value + + render_serialized_payload(201) { serialize_order(order) } + end + + def add_item + variant = Spree::Variant.find(params[:variant_id]) + + spree_authorize! :update, spree_current_order, order_token + spree_authorize! :show, variant + + result = add_item_service.call( + order: spree_current_order, + variant: variant, + quantity: params[:quantity], + options: params[:options] + ) + + render_order(result) + end + + def remove_line_item + spree_authorize! :update, spree_current_order, order_token + + remove_line_item_service.call( + order: spree_current_order, + line_item: line_item + ) + + render_serialized_payload { serialized_current_order } + end + + def empty + spree_authorize! :update, spree_current_order, order_token + + # TODO: we should extract this logic into service and let + # developers overwrite it + spree_current_order.empty! + + render_serialized_payload { serialized_current_order } + end + + def set_quantity + return render_error_item_quantity unless params[:quantity].to_i > 0 + + spree_authorize! :update, spree_current_order, order_token + + result = set_item_quantity_service.call(order: spree_current_order, line_item: line_item, quantity: params[:quantity]) + + render_order(result) + end + + def show + spree_authorize! :show, spree_current_order, order_token + + render_serialized_payload { serialized_current_order } + end + + def apply_coupon_code + spree_authorize! :update, spree_current_order, order_token + + spree_current_order.coupon_code = params[:coupon_code] + result = coupon_handler.new(spree_current_order).apply + + if result.error.blank? + render_serialized_payload { serialized_current_order } + else + render_error_payload(result.error) + end + end + + def remove_coupon_code + spree_authorize! :update, spree_current_order, order_token + + result = coupon_handler.new(spree_current_order).remove(params[:coupon_code]) + + if result.error.blank? + render_serialized_payload { serialized_current_order } + else + render_error_payload(result.error) + end + end + + private + + def resource_serializer + Spree::Api::Dependencies.storefront_cart_serializer.constantize + end + + def create_service + Spree::Api::Dependencies.storefront_cart_create_service.constantize + end + + def add_item_service + Spree::Api::Dependencies.storefront_cart_add_item_service.constantize + end + + def set_item_quantity_service + Spree::Api::Dependencies.storefront_cart_set_item_quantity_service.constantize + end + + def remove_line_item_service + Spree::Api::Dependencies.storefront_cart_remove_line_item_service.constantize + end + + def coupon_handler + Spree::Api::Dependencies.storefront_coupon_handler.constantize + end + + def line_item + @line_item ||= spree_current_order.line_items.find(params[:line_item_id]) + end + + def render_error_item_quantity + render json: { error: I18n.t(:wrong_quantity, scope: 'spree.api.v2.cart') }, status: 422 + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/checkout_controller.rb b/api/app/controllers/spree/api/v2/storefront/checkout_controller.rb new file mode 100644 index 00000000000..66c22474744 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/checkout_controller.rb @@ -0,0 +1,132 @@ +module Spree + module Api + module V2 + module Storefront + class CheckoutController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::Storefront::OrderConcern + before_action :ensure_order + + def next + spree_authorize! :update, spree_current_order, order_token + + result = next_service.call(order: spree_current_order) + + render_order(result) + end + + def advance + spree_authorize! :update, spree_current_order, order_token + + result = advance_service.call(order: spree_current_order) + + render_order(result) + end + + def complete + spree_authorize! :update, spree_current_order, order_token + + result = complete_service.call(order: spree_current_order) + + render_order(result) + end + + def update + spree_authorize! :update, spree_current_order, order_token + + result = update_service.call( + order: spree_current_order, + params: params, + # defined in https://github.com/spree/spree/blob/master/core/lib/spree/core/controller_helpers/strong_parameters.rb#L19 + permitted_attributes: permitted_checkout_attributes, + request_env: request.headers.env + ) + + render_order(result) + end + + def add_store_credit + spree_authorize! :update, spree_current_order, order_token + + result = add_store_credit_service.call( + order: spree_current_order, + amount: params[:amount].try(:to_f) + ) + + render_order(result) + end + + def remove_store_credit + spree_authorize! :update, spree_current_order, order_token + + result = remove_store_credit_service.call(order: spree_current_order) + render_order(result) + end + + def shipping_rates + result = shipping_rates_service.call(order: spree_current_order) + + render_serialized_payload { serialize_shipping_rates(result.value) } + end + + def payment_methods + render_serialized_payload { serialize_payment_methods(spree_current_order.available_payment_methods) } + end + + private + + def resource_serializer + Spree::Api::Dependencies.storefront_cart_serializer.constantize + end + + def next_service + Spree::Api::Dependencies.storefront_checkout_next_service.constantize + end + + def advance_service + Spree::Api::Dependencies.storefront_checkout_advance_service.constantize + end + + def add_store_credit_service + Spree::Api::Dependencies.storefront_checkout_add_store_credit_service.constantize + end + + def remove_store_credit_service + Spree::Api::Dependencies.storefront_checkout_remove_store_credit_service.constantize + end + + def complete_service + Spree::Api::Dependencies.storefront_checkout_complete_service.constantize + end + + def update_service + Spree::Api::Dependencies.storefront_checkout_update_service.constantize + end + + def payment_methods_serializer + Spree::Api::Dependencies.storefront_payment_method_serializer.constantize + end + + def shipping_rates_service + Spree::Api::Dependencies.storefront_checkout_get_shipping_rates_service.constantize + end + + def shipping_rates_serializer + Spree::Api::Dependencies.storefront_shipment_serializer.constantize + end + + def serialize_payment_methods(payment_methods) + payment_methods_serializer.new(payment_methods).serializable_hash + end + + def serialize_shipping_rates(shipments) + shipping_rates_serializer.new( + shipments, + include: [:shipping_rates], + params: { show_rates: true } + ).serializable_hash + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/countries_controller.rb b/api/app/controllers/spree/api/v2/storefront/countries_controller.rb new file mode 100644 index 00000000000..c290adb3c38 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/countries_controller.rb @@ -0,0 +1,61 @@ +module Spree + module Api + module V2 + module Storefront + class CountriesController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::CollectionOptionsHelpers + + def index + render_serialized_payload { serialize_collection(collection) } + end + + def show + render_serialized_payload { serialize_resource(resource) } + end + + private + + def serialize_collection(collection) + collection_serializer.new(collection).serializable_hash + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + fields: sparse_fields, + params: { include_states: true } + ).serializable_hash + end + + def collection + collection_finder.new(scope, params).call + end + + def resource + return scope.default if params[:iso] == 'default' + + scope.find_by(iso: params[:iso]&.upcase) || + scope.find_by(iso3: params[:iso]&.upcase) + end + + def resource_serializer + Spree::Api::Dependencies.storefront_country_serializer.constantize + end + + def collection_serializer + Spree::Api::Dependencies.storefront_country_serializer.constantize + end + + def collection_finder + Spree::Api::Dependencies.storefront_country_finder.constantize + end + + def scope + Spree::Country.accessible_by(current_ability, :read) + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/products_controller.rb b/api/app/controllers/spree/api/v2/storefront/products_controller.rb new file mode 100644 index 00000000000..5a8e95edec3 --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/products_controller.rb @@ -0,0 +1,93 @@ +module Spree + module Api + module V2 + module Storefront + class ProductsController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::CollectionOptionsHelpers + + def index + render_serialized_payload { serialize_collection(paginated_collection) } + end + + def show + render_serialized_payload { serialize_resource(resource) } + end + + private + + def serialize_collection(collection) + collection_serializer.new( + collection, + collection_options(collection) + ).serializable_hash + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + fields: sparse_fields + ).serializable_hash + end + + def paginated_collection + collection_paginator.new(sorted_collection, params).call + end + + def sorted_collection + collection_sorter.new(collection, params, current_currency).call + end + + def collection + collection_finder.new(scope: scope, params: params, current_currency: current_currency).execute + end + + def resource + scope.find_by(slug: params[:id]) || scope.find(params[:id]) + end + + def collection_sorter + Spree::Api::Dependencies.storefront_products_sorter.constantize + end + + def collection_finder + Spree::Api::Dependencies.storefront_products_finder.constantize + end + + def collection_serializer + Spree::Api::Dependencies.storefront_product_serializer.constantize + end + + def resource_serializer + Spree::Api::Dependencies.storefront_product_serializer.constantize + end + + def collection_options(collection) + { + links: collection_links(collection), + meta: collection_meta(collection), + include: resource_includes, + fields: sparse_fields + } + end + + def scope + Spree::Product.accessible_by(current_ability, :read).includes(scope_includes) + end + + def scope_includes + { + master: :default_price, + variants: [], + variant_images: [], + taxons: [], + product_properties: :property, + option_types: :option_values, + variants_including_master: %i[default_price option_values] + } + end + end + end + end + end +end diff --git a/api/app/controllers/spree/api/v2/storefront/taxons_controller.rb b/api/app/controllers/spree/api/v2/storefront/taxons_controller.rb new file mode 100644 index 00000000000..c962c35b9fe --- /dev/null +++ b/api/app/controllers/spree/api/v2/storefront/taxons_controller.rb @@ -0,0 +1,85 @@ +module Spree + module Api + module V2 + module Storefront + class TaxonsController < ::Spree::Api::V2::BaseController + include Spree::Api::V2::CollectionOptionsHelpers + + def index + render_serialized_payload { serialize_collection(paginated_collection) } + end + + def show + render_serialized_payload { serialize_resource(resource) } + end + + private + + def serialize_collection(collection) + collection_serializer.new( + collection, + collection_options(collection) + ).serializable_hash + end + + def serialize_resource(resource) + resource_serializer.new( + resource, + include: resource_includes, + fields: sparse_fields + ).serializable_hash + end + + def collection_serializer + Spree::Api::Dependencies.storefront_taxon_serializer.constantize + end + + def resource_serializer + Spree::Api::Dependencies.storefront_taxon_serializer.constantize + end + + def collection_finder + Spree::Api::Dependencies.storefront_taxon_finder.constantize + end + + def collection_options(collection) + { + links: collection_links(collection), + meta: collection_meta(collection), + include: resource_includes, + fields: sparse_fields + } + end + + def paginated_collection + collection_paginator.new(collection, params).call + end + + def collection + collection_finder.new(scope: scope, params: params).execute + end + + def resource + scope.find_by(permalink: params[:id]) || scope.find(params[:id]) + end + + def scope + Spree::Taxon.accessible_by(current_ability, :read).includes(scope_includes) + end + + def scope_includes + node_includes = %i[icon products parent taxonomy] + + { + parent: node_includes, + children: node_includes, + taxonomy: [root: node_includes], + products: [], + icon: [] + } + end + end + end + end + end +end diff --git a/api/app/helpers/spree/api/api_helpers.rb b/api/app/helpers/spree/api/api_helpers.rb new file mode 100644 index 00000000000..ce086f53553 --- /dev/null +++ b/api/app/helpers/spree/api/api_helpers.rb @@ -0,0 +1,188 @@ +module Spree + module Api + module ApiHelpers + ATTRIBUTES = [ + :product_attributes, + :product_property_attributes, + :variant_attributes, + :image_attributes, + :option_value_attributes, + :order_attributes, + :line_item_attributes, + :option_type_attributes, + :payment_attributes, + :payment_method_attributes, + :shipment_attributes, + :taxonomy_attributes, + :taxon_attributes, + :address_attributes, + :country_attributes, + :state_attributes, + :adjustment_attributes, + :inventory_unit_attributes, + :return_authorization_attributes, + :creditcard_attributes, + :payment_source_attributes, + :user_attributes, + :property_attributes, + :stock_location_attributes, + :stock_movement_attributes, + :stock_item_attributes, + :promotion_attributes, + :store_attributes, + :tag_attributes, + :customer_return_attributes, + :reimbursement_attributes + ] + + mattr_reader *ATTRIBUTES + + def required_fields_for(model) + required_fields = model._validators.select do |_field, validations| + validations.any? { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) } + end.map(&:first) # get fields that are invalid + # Permalinks presence is validated, but are really automatically generated + # Therefore we shouldn't tell API clients that they MUST send one through + required_fields.map!(&:to_s).delete('permalink') + # Do not require slugs, either + required_fields.delete('slug') + required_fields + end + + @@product_attributes = [ + :id, :name, :description, :price, :display_price, :available_on, + :slug, :meta_description, :meta_keywords, :shipping_category_id, + :taxon_ids, :total_on_hand + ] + + @@product_property_attributes = [ + :id, :product_id, :property_id, :value, :property_name + ] + + @@variant_attributes = [ + :id, :name, :sku, :price, :weight, :height, :width, :depth, :is_master, + :slug, :description, :track_inventory + ] + + @@image_attributes = [ + :id, :position, :attachment_content_type, :attachment_file_name, :type, + :attachment_updated_at, :attachment_width, :attachment_height, :alt + ] + + @@option_value_attributes = [ + :id, :name, :presentation, :option_type_name, :option_type_id, + :option_type_presentation + ] + + @@order_attributes = [ + :id, :number, :item_total, :total, :ship_total, :state, :adjustment_total, + :user_id, :created_at, :updated_at, :completed_at, :payment_total, + :shipment_state, :payment_state, :email, :special_instructions, :channel, + :included_tax_total, :additional_tax_total, :display_included_tax_total, + :display_additional_tax_total, :tax_total, :currency, :considered_risky, + :canceler_id + ] + + @@line_item_attributes = [:id, :quantity, :price, :variant_id] + + @@option_type_attributes = [:id, :name, :presentation, :position] + + @@payment_attributes = [ + :id, :source_type, :source_id, :amount, :display_amount, + :payment_method_id, :state, :avs_response, :created_at, + :updated_at, :number + ] + + @@payment_method_attributes = [:id, :name, :description] + + @@shipment_attributes = [:id, :tracking, :number, :cost, :shipped_at, :state] + + @@taxonomy_attributes = [:id, :name] + + @@taxon_attributes = [ + :id, :name, :pretty_name, :permalink, :parent_id, + :taxonomy_id, :meta_title, :meta_description + ] + + @@inventory_unit_attributes = [ + :id, :lock_version, :state, :variant_id, :shipment_id, + :return_authorization_id + ] + + @@return_authorization_attributes = [ + :id, :number, :state, :order_id, :memo, :created_at, :updated_at + ] + + @@address_attributes = [ + :id, :firstname, :lastname, :full_name, :address1, :address2, :city, + :zipcode, :phone, :company, :alternative_phone, :country_id, :state_id, + :state_name, :state_text + ] + + @@country_attributes = [:id, :iso_name, :iso, :iso3, :name, :numcode] + + @@state_attributes = [:id, :name, :abbr, :country_id] + + @@adjustment_attributes = [ + :id, :source_type, :source_id, :adjustable_type, :adjustable_id, + :originator_type, :originator_id, :amount, :label, :mandatory, + :locked, :eligible, :created_at, :updated_at + ] + + @@creditcard_attributes = [ + :id, :month, :year, :cc_type, :last_digits, :name, + :gateway_customer_profile_id, :gateway_payment_profile_id + ] + + @@payment_source_attributes = [ + :id, :month, :year, :cc_type, :last_digits, :name + ] + + @@user_attributes = [:id, :email, :created_at, :updated_at] + + @@property_attributes = [:id, :name, :presentation] + + @@stock_location_attributes = [ + :id, :name, :address1, :address2, :city, :state_id, :state_name, + :country_id, :zipcode, :phone, :active + ] + + @@stock_movement_attributes = [:id, :quantity, :stock_item_id] + + @@stock_item_attributes = [ + :id, :count_on_hand, :backorderable, :lock_version, :stock_location_id, + :variant_id + ] + + @@promotion_attributes = [ + :id, :name, :description, :expires_at, :starts_at, :type, :usage_limit, + :match_policy, :code, :advertise, :path + ] + + @@store_attributes = [ + :id, :name, :url, :meta_description, :meta_keywords, :seo_title, + :mail_from_address, :default_currency, :code, :default + ] + + @@tag_attributes = [:id, :name] + + @@customer_return_attributes = [ + :id, :number, :order_id, :fully_reimbursed?, :pre_tax_total, + :created_at, :updated_at + ] + + @@reimbursement_attributes = [ + :id, :reimbursement_status, :customer_return_id, :order_id, + :number, :total, :created_at, :updated_at + ] + + def variant_attributes + if @current_user_roles&.include?('admin') + @@variant_attributes + [:cost_price] + else + @@variant_attributes + end + end + end + end +end diff --git a/api/app/helpers/spree/api/v2/collection_options_helpers.rb b/api/app/helpers/spree/api/v2/collection_options_helpers.rb new file mode 100644 index 00000000000..daa4dd44020 --- /dev/null +++ b/api/app/helpers/spree/api/v2/collection_options_helpers.rb @@ -0,0 +1,37 @@ +module Spree + module Api + module V2 + module CollectionOptionsHelpers + def collection_links(collection) + { + self: request.original_url, + next: pagination_url(collection.next_page || collection.total_pages), + prev: pagination_url(collection.prev_page || 1), + last: pagination_url(collection.total_pages), + first: pagination_url(1) + } + end + + def collection_meta(collection) + { + count: collection.size, + total_count: collection.total_count, + total_pages: collection.total_pages + } + end + + # leaving this method in public scope so it's still possible to modify + # those params to support non-standard non-JSON API parameters + def collection_permitted_params + params.permit(:format, :page, :per_page, :sort, :include, :fields, filter: {}) + end + + private + + def pagination_url(page) + url_for(collection_permitted_params.merge(page: page)) + end + end + end + end +end diff --git a/api/app/models/concerns/spree/user_api_authentication.rb b/api/app/models/concerns/spree/user_api_authentication.rb new file mode 100644 index 00000000000..600d5c107b1 --- /dev/null +++ b/api/app/models/concerns/spree/user_api_authentication.rb @@ -0,0 +1,19 @@ +module Spree + module UserApiAuthentication + def generate_spree_api_key! + self.spree_api_key = generate_spree_api_key + save! + end + + def clear_spree_api_key! + self.spree_api_key = nil + save! + end + + private + + def generate_spree_api_key + SecureRandom.hex(24) + end + end +end diff --git a/api/app/models/concerns/spree/user_api_methods.rb b/api/app/models/concerns/spree/user_api_methods.rb new file mode 100644 index 00000000000..2f5f813d593 --- /dev/null +++ b/api/app/models/concerns/spree/user_api_methods.rb @@ -0,0 +1,7 @@ +module Spree + module UserApiMethods + extend ActiveSupport::Concern + + include Spree::UserApiAuthentication + end +end diff --git a/api/app/models/doorkeeper/access_grant_decorator.rb b/api/app/models/doorkeeper/access_grant_decorator.rb new file mode 100644 index 00000000000..17c5b3c1c31 --- /dev/null +++ b/api/app/models/doorkeeper/access_grant_decorator.rb @@ -0,0 +1,3 @@ +Doorkeeper::AccessGrant.class_eval do + self.table_name = 'spree_oauth_access_grants' +end diff --git a/api/app/models/doorkeeper/access_token_decorator.rb b/api/app/models/doorkeeper/access_token_decorator.rb new file mode 100644 index 00000000000..72f839cb74b --- /dev/null +++ b/api/app/models/doorkeeper/access_token_decorator.rb @@ -0,0 +1,3 @@ +Doorkeeper::AccessToken.class_eval do + self.table_name = 'spree_oauth_access_tokens' +end diff --git a/api/app/models/doorkeeper/application_decorator.rb b/api/app/models/doorkeeper/application_decorator.rb new file mode 100644 index 00000000000..bf4c378d909 --- /dev/null +++ b/api/app/models/doorkeeper/application_decorator.rb @@ -0,0 +1,3 @@ +Doorkeeper::Application.class_eval do + self.table_name = 'spree_oauth_applications' +end diff --git a/api/app/models/spree/api_configuration.rb b/api/app/models/spree/api_configuration.rb new file mode 100644 index 00000000000..28bc94d2219 --- /dev/null +++ b/api/app/models/spree/api_configuration.rb @@ -0,0 +1,5 @@ +module Spree + class ApiConfiguration < Preferences::Configuration + preference :requires_authentication, :boolean, default: true + end +end diff --git a/api/app/models/spree/api_dependencies.rb b/api/app/models/spree/api_dependencies.rb new file mode 100644 index 00000000000..9d1c6f48ab2 --- /dev/null +++ b/api/app/models/spree/api_dependencies.rb @@ -0,0 +1,77 @@ +module Spree + class ApiDependencies + include Spree::DependenciesHelper + + INJECTION_POINTS = [ + :storefront_cart_create_service, :storefront_cart_add_item_service, :storefront_cart_remove_line_item_service, + :storefront_cart_remove_item_service, :storefront_cart_set_item_quantity_service, :storefront_cart_recalculate_service, + :storefront_cart_update, :storefront_coupon_handler, :storefront_checkout_next_service, :storefront_checkout_advance_service, + :storefront_checkout_update_service, :storefront_checkout_complete_service, :storefront_checkout_add_store_credit_service, + :storefront_checkout_remove_store_credit_service, :storefront_checkout_get_shipping_rates_service, + :storefront_cart_compare_line_items_service, :storefront_cart_serializer, :storefront_credit_card_serializer, + :storefront_credit_card_finder, :storefront_shipment_serializer, :storefront_payment_method_serializer, :storefront_country_finder, + :storefront_country_serializer, :storefront_current_order_finder, :storefront_completed_order_finder, :storefront_order_sorter, + :storefront_collection_paginator, :storefront_user_serializer, :storefront_products_sorter, :storefront_products_finder, + :storefront_product_serializer, :storefront_taxon_serializer, :storefront_taxon_finder, :storefront_find_by_variant_finder, + :storefront_cart_update_service + ].freeze + + attr_accessor *INJECTION_POINTS + + def initialize + set_storefront_defaults + end + + private + + def set_storefront_defaults + # cart services + @storefront_cart_create_service = Spree::Dependencies.cart_create_service + @storefront_cart_add_item_service = Spree::Dependencies.cart_add_item_service + @storefront_cart_compare_line_items_service = Spree::Dependencies.cart_compare_line_items_service + @storefront_cart_update_service = Spree::Dependencies.cart_update_service + @storefront_cart_remove_line_item_service = Spree::Dependencies.cart_remove_line_item_service + @storefront_cart_remove_item_service = Spree::Dependencies.cart_remove_item_service + @storefront_cart_set_item_quantity_service = Spree::Dependencies.cart_set_item_quantity_service + @storefront_cart_recalculate_service = Spree::Dependencies.cart_recalculate_service + + # coupon code handler + @storefront_coupon_handler = Spree::Dependencies.coupon_handler + + # checkout services + @storefront_checkout_next_service = Spree::Dependencies.checkout_next_service + @storefront_checkout_advance_service = Spree::Dependencies.checkout_advance_service + @storefront_checkout_update_service = Spree::Dependencies.checkout_update_service + @storefront_checkout_complete_service = Spree::Dependencies.checkout_complete_service + @storefront_checkout_add_store_credit_service = Spree::Dependencies.checkout_add_store_credit_service + @storefront_checkout_remove_store_credit_service = Spree::Dependencies.checkout_remove_store_credit_service + @storefront_checkout_get_shipping_rates_service = Spree::Dependencies.checkout_get_shipping_rates_service + + # serializers + @storefront_cart_serializer = 'Spree::V2::Storefront::CartSerializer' + @storefront_credit_card_serializer = 'Spree::V2::Storefront::CreditCardSerializer' + @storefront_country_serializer = 'Spree::V2::Storefront::CountrySerializer' + @storefront_user_serializer = 'Spree::V2::Storefront::UserSerializer' + @storefront_shipment_serializer = 'Spree::V2::Storefront::ShipmentSerializer' + @storefront_taxon_serializer = 'Spree::V2::Storefront::TaxonSerializer' + @storefront_payment_method_serializer = 'Spree::V2::Storefront::PaymentMethodSerializer' + @storefront_product_serializer = 'Spree::V2::Storefront::ProductSerializer' + + # sorters + @storefront_order_sorter = Spree::Dependencies.order_sorter + @storefront_products_sorter = Spree::Dependencies.products_sorter + + # paginators + @storefront_collection_paginator = Spree::Dependencies.collection_paginator + + # finders + @storefront_country_finder = Spree::Dependencies.country_finder + @storefront_current_order_finder = Spree::Dependencies.current_order_finder + @storefront_completed_order_finder = Spree::Dependencies.completed_order_finder + @storefront_credit_card_finder = Spree::Dependencies.credit_card_finder + @storefront_find_by_variant_finder = Spree::Dependencies.line_item_by_variant_finder + @storefront_products_finder = Spree::Dependencies.products_finder + @storefront_taxon_finder = Spree::Dependencies.taxon_finder + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/address_serializer.rb b/api/app/serializers/spree/v2/storefront/address_serializer.rb new file mode 100644 index 00000000000..5eb18132461 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/address_serializer.rb @@ -0,0 +1,15 @@ +module Spree + module V2 + module Storefront + class AddressSerializer < BaseSerializer + set_type :address + + attributes :firstname, :lastname, :address1, :address2, :city, :zipcode, :phone, :state_name, + :company, :country_name, :country_iso3, :country_iso + + attribute :state_code, &:state_abbr + attribute :state_name, &:state_name_text + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/base_serializer.rb b/api/app/serializers/spree/v2/storefront/base_serializer.rb new file mode 100644 index 00000000000..acb6e19bdf0 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/base_serializer.rb @@ -0,0 +1,9 @@ +module Spree + module V2 + module Storefront + class BaseSerializer + include FastJsonapi::ObjectSerializer + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/cart_serializer.rb b/api/app/serializers/spree/v2/storefront/cart_serializer.rb new file mode 100644 index 00000000000..00c849db7f6 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/cart_serializer.rb @@ -0,0 +1,40 @@ +module Spree + module V2 + module Storefront + class CartSerializer < BaseSerializer + set_type :cart + + attributes :number, :item_total, :total, :ship_total, :adjustment_total, :created_at, + :updated_at, :completed_at, :included_tax_total, :additional_tax_total, :display_additional_tax_total, + :display_included_tax_total, :tax_total, :currency, :state, :token, :email, + :display_item_total, :display_ship_total, :display_adjustment_total, :display_tax_total, + :promo_total, :display_promo_total, :item_count, :special_instructions, :display_total + + has_many :line_items + has_many :variants + has_many :promotions, id_method_name: :promotion_id do |cart| + # we only want to display applied and valid promotions + # sometimes Order can have multiple promotions but the promo engine + # will only apply those that are more beneficial for the customer + # TODO: we should probably move this code out of the serializer + promotion_ids = cart.all_adjustments.eligible.nonzero.promotion.map { |a| a.source.promotion_id }.uniq + + cart.order_promotions.where(promotion_id: promotion_ids).uniq(&:promotion_id) + end + has_many :payments do |cart| + cart.payments.valid + end + has_many :shipments + + belongs_to :user + belongs_to :billing_address, + id_method_name: :bill_address_id, + serializer: :address + + belongs_to :shipping_address, + id_method_name: :ship_address_id, + serializer: :address + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/country_serializer.rb b/api/app/serializers/spree/v2/storefront/country_serializer.rb new file mode 100644 index 00000000000..30775fc4ef1 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/country_serializer.rb @@ -0,0 +1,18 @@ +module Spree + module V2 + module Storefront + class CountrySerializer < BaseSerializer + set_type :country + + attributes :iso, :iso3, :iso_name, :name, :states_required, + :zipcode_required + + attribute :default do |object| + object.default? + end + + has_many :states, if: proc { |_record, params| params && params[:include_states] } + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/credit_card_serializer.rb b/api/app/serializers/spree/v2/storefront/credit_card_serializer.rb new file mode 100644 index 00000000000..c1b9b7f69c6 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/credit_card_serializer.rb @@ -0,0 +1,13 @@ +module Spree + module V2 + module Storefront + class CreditCardSerializer < BaseSerializer + set_type :credit_card + + attributes :cc_type, :last_digits, :month, :year, :name, :default + + belongs_to :payment_method + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/image_serializer.rb b/api/app/serializers/spree/v2/storefront/image_serializer.rb new file mode 100644 index 00000000000..8dcac025c40 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/image_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class ImageSerializer < BaseSerializer + set_type :image + + attributes :viewable_type, :viewable_id, :styles + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/line_item_serializer.rb b/api/app/serializers/spree/v2/storefront/line_item_serializer.rb new file mode 100644 index 00000000000..0683ae33855 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/line_item_serializer.rb @@ -0,0 +1,18 @@ +module Spree + module V2 + module Storefront + class LineItemSerializer < BaseSerializer + set_type :line_item + + attributes :name, :quantity, :price, :slug, :options_text, :currency, + :display_price, :total, :display_total, :adjustment_total, + :display_adjustment_total, :additional_tax_total, + :discounted_amount, :display_discounted_amount, + :display_additional_tax_total, :promo_total, :display_promo_total, + :included_tax_total, :display_included_tax_total + + belongs_to :variant + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/option_type_serializer.rb b/api/app/serializers/spree/v2/storefront/option_type_serializer.rb new file mode 100644 index 00000000000..ecb31681247 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/option_type_serializer.rb @@ -0,0 +1,13 @@ +module Spree + module V2 + module Storefront + class OptionTypeSerializer < BaseSerializer + set_type :option_type + + attributes :name, :presentation, :position + + has_many :option_values + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/option_value_serializer.rb b/api/app/serializers/spree/v2/storefront/option_value_serializer.rb new file mode 100644 index 00000000000..8275f01a2fe --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/option_value_serializer.rb @@ -0,0 +1,13 @@ +module Spree + module V2 + module Storefront + class OptionValueSerializer < BaseSerializer + set_type :option_value + + attributes :name, :presentation, :position + + belongs_to :option_type + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/payment_method_serializer.rb b/api/app/serializers/spree/v2/storefront/payment_method_serializer.rb new file mode 100644 index 00000000000..de1abf90fee --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/payment_method_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class PaymentMethodSerializer < BaseSerializer + set_type :payment_method + + attributes :type, :name, :description + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/payment_serializer.rb b/api/app/serializers/spree/v2/storefront/payment_serializer.rb new file mode 100644 index 00000000000..78e4f0bd978 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/payment_serializer.rb @@ -0,0 +1,15 @@ +module Spree + module V2 + module Storefront + class PaymentSerializer < BaseSerializer + set_type :payment + + has_one :source, polymorphic: true + has_one :payment_method + + attributes :amount, :response_code, :number, :cvv_response_code, :cvv_response_message, + :payment_method_id, :payment_method_name + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/product_property_serializer.rb b/api/app/serializers/spree/v2/storefront/product_property_serializer.rb new file mode 100644 index 00000000000..166f3ac0776 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/product_property_serializer.rb @@ -0,0 +1,14 @@ +module Spree + module V2 + module Storefront + class ProductPropertySerializer < BaseSerializer + set_type :product_property + + attribute :value + + attribute :name, &:property_name + attribute :description, &:property_presentation + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/product_serializer.rb b/api/app/serializers/spree/v2/storefront/product_serializer.rb new file mode 100644 index 00000000000..3af2b073c04 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/product_serializer.rb @@ -0,0 +1,35 @@ +module Spree + module V2 + module Storefront + class ProductSerializer < BaseSerializer + set_type :product + + attributes :name, :description, :price, :currency, :display_price, + :available_on, :slug, :meta_description, :meta_keywords, + :updated_at + + attribute :purchasable, &:purchasable? + attribute :in_stock, &:in_stock? + attribute :backorderable, &:backorderable? + + has_many :variants + has_many :option_types + has_many :product_properties + has_many :taxons + + # all images from all variants + has_many :images, + object_method_name: :variant_images, + id_method_name: :variant_image_ids, + record_type: :image, + serializer: :image + + has_one :default_variant, + object_method_name: :default_variant, + id_method_name: :default_variant_id, + record_type: :variant, + serializer: :variant + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/promotion_serializer.rb b/api/app/serializers/spree/v2/storefront/promotion_serializer.rb new file mode 100644 index 00000000000..bdc64fbd3a6 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/promotion_serializer.rb @@ -0,0 +1,12 @@ +module Spree + module V2 + module Storefront + class PromotionSerializer < BaseSerializer + set_id :promotion_id + set_type :promotion + + attributes :name, :description, :amount, :display_amount, :code + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/shipment_serializer.rb b/api/app/serializers/spree/v2/storefront/shipment_serializer.rb new file mode 100644 index 00000000000..d7a2c8cf4bd --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/shipment_serializer.rb @@ -0,0 +1,16 @@ +module Spree + module V2 + module Storefront + class ShipmentSerializer < BaseSerializer + set_type :shipment + + attributes :number, :final_price, :display_final_price, + :state, :shipped_at, :tracking_url + + attribute :free, &:free? + + has_many :shipping_rates, if: proc { |_record, params| params&.dig(:show_rates) } + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/shipping_rate_serializer.rb b/api/app/serializers/spree/v2/storefront/shipping_rate_serializer.rb new file mode 100644 index 00000000000..dd8d7c16341 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/shipping_rate_serializer.rb @@ -0,0 +1,14 @@ +module Spree + module V2 + module Storefront + class ShippingRateSerializer < BaseSerializer + set_type :shipping_rate + + attributes :name, :selected, :final_price, :display_final_price, :cost, + :display_cost, :tax_amount, :display_tax_amount, :shipping_method_id + + attribute :free, &:free? + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/state_serializer.rb b/api/app/serializers/spree/v2/storefront/state_serializer.rb new file mode 100644 index 00000000000..06d5b588ff6 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/state_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class StateSerializer < BaseSerializer + set_type :state + + attributes :abbr, :name + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/store_credit_event_serializer.rb b/api/app/serializers/spree/v2/storefront/store_credit_event_serializer.rb new file mode 100644 index 00000000000..e8c34d80089 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/store_credit_event_serializer.rb @@ -0,0 +1,15 @@ +module Spree + module V2 + module Storefront + class StoreCreditEventSerializer < BaseSerializer + set_type :store_credit_event + + attributes :action, :amount, :user_total_amount, :created_at + + attribute :order_number do |object| + object.order&.number + end + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/store_credit_serializer.rb b/api/app/serializers/spree/v2/storefront/store_credit_serializer.rb new file mode 100644 index 00000000000..18b0cfa1cb8 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/store_credit_serializer.rb @@ -0,0 +1,17 @@ +module Spree + module V2 + module Storefront + class StoreCreditSerializer < BaseSerializer + set_type :store_credit + + belongs_to :category + has_many :store_credit_events + belongs_to :credit_type, + id_method_name: :type_id, + serializer: :store_credit_type + + attributes :amount, :amount_used, :created_at + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/store_credit_type_serializer.rb b/api/app/serializers/spree/v2/storefront/store_credit_type_serializer.rb new file mode 100644 index 00000000000..3cb4745a2a6 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/store_credit_type_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class StoreCreditTypeSerializer < BaseSerializer + set_type :store_credit_type + + attributes :name + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/taxon_image_serializer.rb b/api/app/serializers/spree/v2/storefront/taxon_image_serializer.rb new file mode 100644 index 00000000000..a18125a20d1 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/taxon_image_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class TaxonImageSerializer < BaseSerializer + set_type :taxon_image + + attributes :viewable_type, :viewable_id, :styles + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/taxon_serializer.rb b/api/app/serializers/spree/v2/storefront/taxon_serializer.rb new file mode 100644 index 00000000000..02d38fdce61 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/taxon_serializer.rb @@ -0,0 +1,28 @@ +module Spree + module V2 + module Storefront + class TaxonSerializer < BaseSerializer + set_type :taxon + + attributes :name, :pretty_name, :permalink, :seo_title, :meta_title, :meta_description, + :meta_keywords, :left, :right, :position, :depth, :updated_at + + attribute :is_root, &:root? + attribute :is_child, &:child? + attribute :is_leaf, &:leaf? + + belongs_to :parent, record_type: :taxon, serializer: :taxon + belongs_to :taxonomy, record_type: :taxonomy + + has_many :children, record_type: :child, serializer: :taxon + has_many :products, record_type: :product + + has_one :image, + object_method_name: :icon, + id_method_name: :icon_id, + record_type: :taxon_image, + serializer: :taxon_image + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/taxonomy_serializer.rb b/api/app/serializers/spree/v2/storefront/taxonomy_serializer.rb new file mode 100644 index 00000000000..49e5ff6e8b3 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/taxonomy_serializer.rb @@ -0,0 +1,11 @@ +module Spree + module V2 + module Storefront + class TaxonomySerializer < BaseSerializer + set_type :taxonomy + + attributes :name, :position + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/user_serializer.rb b/api/app/serializers/spree/v2/storefront/user_serializer.rb new file mode 100644 index 00000000000..1736bb653d9 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/user_serializer.rb @@ -0,0 +1,29 @@ +module Spree + module V2 + module Storefront + class UserSerializer < BaseSerializer + set_type :user + + attributes :email + + attribute :store_credits, &:total_available_store_credit + + attribute :completed_orders do |object| + object.orders.complete.count + end + + has_one :default_billing_address, + id_method_name: :bill_address_id, + object_method_name: :bill_address, + record_type: :address, + serializer: :address + + has_one :default_shipping_address, + id_method_name: :ship_address_id, + object_method_name: :ship_address, + record_type: :address, + serializer: :address + end + end + end +end diff --git a/api/app/serializers/spree/v2/storefront/variant_serializer.rb b/api/app/serializers/spree/v2/storefront/variant_serializer.rb new file mode 100644 index 00000000000..40f575c1140 --- /dev/null +++ b/api/app/serializers/spree/v2/storefront/variant_serializer.rb @@ -0,0 +1,20 @@ +module Spree + module V2 + module Storefront + class VariantSerializer < BaseSerializer + set_type :variant + + attributes :name, :sku, :price, :currency, :display_price, :weight, :height, + :width, :depth, :is_master, :options_text, :slug, :description + + attribute :purchasable, &:purchasable? + attribute :in_stock, &:in_stock? + attribute :backorderable, &:backorderable? + + belongs_to :product + has_many :images + has_many :option_values + end + end + end +end diff --git a/api/app/views/spree/api/errors/gateway_error.v1.rabl b/api/app/views/spree/api/errors/gateway_error.v1.rabl new file mode 100644 index 00000000000..4df2620dee6 --- /dev/null +++ b/api/app/views/spree/api/errors/gateway_error.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:gateway_error, scope: 'spree.api', text: @error) } diff --git a/api/app/views/spree/api/errors/invalid_api_key.v1.rabl b/api/app/views/spree/api/errors/invalid_api_key.v1.rabl new file mode 100644 index 00000000000..2c22e71fb38 --- /dev/null +++ b/api/app/views/spree/api/errors/invalid_api_key.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:invalid_api_key, key: api_key, scope: 'spree.api') } diff --git a/api/app/views/spree/api/errors/invalid_resource.v1.rabl b/api/app/views/spree/api/errors/invalid_resource.v1.rabl new file mode 100644 index 00000000000..5ce64180096 --- /dev/null +++ b/api/app/views/spree/api/errors/invalid_resource.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:error) { I18n.t(:invalid_resource, scope: 'spree.api') } +node(:errors) { @resource.errors.to_hash } diff --git a/api/app/views/spree/api/errors/must_specify_api_key.v1.rabl b/api/app/views/spree/api/errors/must_specify_api_key.v1.rabl new file mode 100644 index 00000000000..5c34ad9dda9 --- /dev/null +++ b/api/app/views/spree/api/errors/must_specify_api_key.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:must_specify_api_key, scope: 'spree.api') } diff --git a/api/app/views/spree/api/errors/not_found.v1.rabl b/api/app/views/spree/api/errors/not_found.v1.rabl new file mode 100644 index 00000000000..1fba5b72985 --- /dev/null +++ b/api/app/views/spree/api/errors/not_found.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:resource_not_found, scope: 'spree.api') } diff --git a/api/app/views/spree/api/errors/unauthorized.v1.rabl b/api/app/views/spree/api/errors/unauthorized.v1.rabl new file mode 100644 index 00000000000..4073205773b --- /dev/null +++ b/api/app/views/spree/api/errors/unauthorized.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:unauthorized, scope: 'spree.api') } diff --git a/api/app/views/spree/api/v1/addresses/show.v1.rabl b/api/app/views/spree/api/v1/addresses/show.v1.rabl new file mode 100644 index 00000000000..2d771704bf2 --- /dev/null +++ b/api/app/views/spree/api/v1/addresses/show.v1.rabl @@ -0,0 +1,10 @@ +object @address +cache [I18n.locale, root_object] +attributes *address_attributes + +child(:country) do |_address| + attributes *country_attributes +end +child(:state) do |_address| + attributes *state_attributes +end diff --git a/api/app/views/spree/api/v1/adjustments/show.v1.rabl b/api/app/views/spree/api/v1/adjustments/show.v1.rabl new file mode 100644 index 00000000000..e88c3d801d7 --- /dev/null +++ b/api/app/views/spree/api/v1/adjustments/show.v1.rabl @@ -0,0 +1,4 @@ +object @adjustment +cache [I18n.locale, root_object] +attributes *adjustment_attributes +node(:display_amount) { |a| a.display_amount.to_s } diff --git a/api/app/views/spree/api/v1/countries/index.v1.rabl b/api/app/views/spree/api/v1/countries/index.v1.rabl new file mode 100644 index 00000000000..d153ae1ed08 --- /dev/null +++ b/api/app/views/spree/api/v1/countries/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@countries => :countries) do + attributes *country_attributes +end +node(:count) { @countries.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @countries.total_pages } diff --git a/api/app/views/spree/api/v1/countries/show.v1.rabl b/api/app/views/spree/api/v1/countries/show.v1.rabl new file mode 100644 index 00000000000..b485c3c11fd --- /dev/null +++ b/api/app/views/spree/api/v1/countries/show.v1.rabl @@ -0,0 +1,5 @@ +object @country +attributes *country_attributes +child states: :states do + attributes :id, :name, :abbr, :country_id +end diff --git a/api/app/views/spree/api/v1/credit_cards/index.v1.rabl b/api/app/views/spree/api/v1/credit_cards/index.v1.rabl new file mode 100644 index 00000000000..1eb0e53cf29 --- /dev/null +++ b/api/app/views/spree/api/v1/credit_cards/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@credit_cards => :credit_cards) do + extends 'spree/api/v1/credit_cards/show' +end +node(:count) { @credit_cards.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @credit_cards.total_pages } diff --git a/api/app/views/spree/api/v1/credit_cards/show.v1.rabl b/api/app/views/spree/api/v1/credit_cards/show.v1.rabl new file mode 100644 index 00000000000..57d734d6abf --- /dev/null +++ b/api/app/views/spree/api/v1/credit_cards/show.v1.rabl @@ -0,0 +1,3 @@ +object @credit_card +cache [I18n.locale, root_object] +attributes *creditcard_attributes diff --git a/api/app/views/spree/api/v1/customer_returns/index.v1.rabl b/api/app/views/spree/api/v1/customer_returns/index.v1.rabl new file mode 100644 index 00000000000..d9b09853710 --- /dev/null +++ b/api/app/views/spree/api/v1/customer_returns/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@collection => :customer_returns) do + attributes *customer_return_attributes +end +node(:count) { @collection.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @collection.total_pages } diff --git a/api/app/views/spree/api/v1/images/index.v1.rabl b/api/app/views/spree/api/v1/images/index.v1.rabl new file mode 100644 index 00000000000..60bec605280 --- /dev/null +++ b/api/app/views/spree/api/v1/images/index.v1.rabl @@ -0,0 +1,4 @@ +object false +child(@images => :images) do + extends 'spree/api/v1/images/show' +end diff --git a/api/app/views/spree/api/v1/images/new.v1.rabl b/api/app/views/spree/api/v1/images/new.v1.rabl new file mode 100644 index 00000000000..102585c15a0 --- /dev/null +++ b/api/app/views/spree/api/v1/images/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*image_attributes] } +node(:required_attributes) { required_fields_for(Spree::Image) } diff --git a/api/app/views/spree/api/v1/images/show.v1.rabl b/api/app/views/spree/api/v1/images/show.v1.rabl new file mode 100644 index 00000000000..d8eb5e3e903 --- /dev/null +++ b/api/app/views/spree/api/v1/images/show.v1.rabl @@ -0,0 +1,6 @@ +object @image +attributes *image_attributes +attributes :viewable_type, :viewable_id +Spree::Image.styles.each do |k, _v| + node("#{k}_url") { |i| main_app.url_for(i.url(k)) } +end diff --git a/api/app/views/spree/api/v1/inventory_units/show.rabl b/api/app/views/spree/api/v1/inventory_units/show.rabl new file mode 100644 index 00000000000..d9e7960b503 --- /dev/null +++ b/api/app/views/spree/api/v1/inventory_units/show.rabl @@ -0,0 +1,2 @@ +object @inventory_unit +attributes *inventory_unit_attributes diff --git a/api/app/views/spree/api/v1/line_items/new.v1.rabl b/api/app/views/spree/api/v1/line_items/new.v1.rabl new file mode 100644 index 00000000000..6269ff819ee --- /dev/null +++ b/api/app/views/spree/api/v1/line_items/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*line_item_attributes] - [:id] } +node(:required_attributes) { [:variant_id, :quantity] } diff --git a/api/app/views/spree/api/v1/line_items/show.v1.rabl b/api/app/views/spree/api/v1/line_items/show.v1.rabl new file mode 100644 index 00000000000..1de39cb4b09 --- /dev/null +++ b/api/app/views/spree/api/v1/line_items/show.v1.rabl @@ -0,0 +1,14 @@ +object @line_item +cache [I18n.locale, root_object] +attributes *line_item_attributes +node(:single_display_amount) { |li| li.single_display_amount.to_s } +node(:display_amount) { |li| li.display_amount.to_s } +node(:total, &:total) +child :variant do + extends 'spree/api/v1/variants/small' + attributes :product_id +end + +child adjustments: :adjustments do + extends 'spree/api/v1/adjustments/show' +end diff --git a/api/app/views/spree/api/v1/option_types/index.v1.rabl b/api/app/views/spree/api/v1/option_types/index.v1.rabl new file mode 100644 index 00000000000..3f1f70659b7 --- /dev/null +++ b/api/app/views/spree/api/v1/option_types/index.v1.rabl @@ -0,0 +1,3 @@ +collection @option_types + +extends 'spree/api/v1/option_types/show' diff --git a/api/app/views/spree/api/v1/option_types/new.v1.rabl b/api/app/views/spree/api/v1/option_types/new.v1.rabl new file mode 100644 index 00000000000..74a74b7dfb8 --- /dev/null +++ b/api/app/views/spree/api/v1/option_types/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*option_type_attributes] } +node(:required_attributes) { required_fields_for(Spree::OptionType) } diff --git a/api/app/views/spree/api/v1/option_types/show.v1.rabl b/api/app/views/spree/api/v1/option_types/show.v1.rabl new file mode 100644 index 00000000000..8e8d0b33668 --- /dev/null +++ b/api/app/views/spree/api/v1/option_types/show.v1.rabl @@ -0,0 +1,5 @@ +object @option_type +attributes *option_type_attributes +child option_values: :option_values do + attributes *option_value_attributes +end diff --git a/api/app/views/spree/api/v1/option_values/index.v1.rabl b/api/app/views/spree/api/v1/option_values/index.v1.rabl new file mode 100644 index 00000000000..d3592c98cd2 --- /dev/null +++ b/api/app/views/spree/api/v1/option_values/index.v1.rabl @@ -0,0 +1,3 @@ +collection @option_values + +extends 'spree/api/v1/option_values/show' diff --git a/api/app/views/spree/api/v1/option_values/new.v1.rabl b/api/app/views/spree/api/v1/option_values/new.v1.rabl new file mode 100644 index 00000000000..662f9de4485 --- /dev/null +++ b/api/app/views/spree/api/v1/option_values/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*option_value_attributes] } +node(:required_attributes) { required_fields_for(Spree::OptionValue) } diff --git a/api/app/views/spree/api/v1/option_values/show.v1.rabl b/api/app/views/spree/api/v1/option_values/show.v1.rabl new file mode 100644 index 00000000000..a3f6680d2ef --- /dev/null +++ b/api/app/views/spree/api/v1/option_values/show.v1.rabl @@ -0,0 +1,2 @@ +object @option_value +attributes *option_value_attributes diff --git a/public/favicon.ico b/api/app/views/spree/api/v1/orders/address.v1.rabl similarity index 100% rename from public/favicon.ico rename to api/app/views/spree/api/v1/orders/address.v1.rabl diff --git a/public/plugin_assets/spree/favicon.ico b/api/app/views/spree/api/v1/orders/canceled.v1.rabl similarity index 100% rename from public/plugin_assets/spree/favicon.ico rename to api/app/views/spree/api/v1/orders/canceled.v1.rabl diff --git a/vendor/plugins/acts_as_tree/test/abstract_unit.rb b/api/app/views/spree/api/v1/orders/cart.v1.rabl similarity index 100% rename from vendor/plugins/acts_as_tree/test/abstract_unit.rb rename to api/app/views/spree/api/v1/orders/cart.v1.rabl diff --git a/vendor/plugins/acts_as_tree/test/database.yml b/api/app/views/spree/api/v1/orders/complete.v1.rabl similarity index 100% rename from vendor/plugins/acts_as_tree/test/database.yml rename to api/app/views/spree/api/v1/orders/complete.v1.rabl diff --git a/api/app/views/spree/api/v1/orders/could_not_apply_coupon.v1.rabl b/api/app/views/spree/api/v1/orders/could_not_apply_coupon.v1.rabl new file mode 100644 index 00000000000..0a457130637 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/could_not_apply_coupon.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { @coupon_message } diff --git a/api/app/views/spree/api/v1/orders/could_not_transition.v1.rabl b/api/app/views/spree/api/v1/orders/could_not_transition.v1.rabl new file mode 100644 index 00000000000..c2bfb5c84a5 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/could_not_transition.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:error) { I18n.t(:could_not_transition, scope: 'spree.api.order') } +node(:errors) { @order.errors.to_hash } diff --git a/api/app/views/spree/api/v1/orders/index.v1.rabl b/api/app/views/spree/api/v1/orders/index.v1.rabl new file mode 100644 index 00000000000..27acb95f307 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@orders => :orders) do + extends 'spree/api/v1/orders/order' +end +node(:count) { @orders.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @orders.total_pages } diff --git a/api/app/views/spree/api/v1/orders/insufficient_quantity.v1.rabl b/api/app/views/spree/api/v1/orders/insufficient_quantity.v1.rabl new file mode 100644 index 00000000000..2cd37ffdf0e --- /dev/null +++ b/api/app/views/spree/api/v1/orders/insufficient_quantity.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { Spree.t(:insufficient_quantity, scope: [:api, :order]) } diff --git a/api/app/views/spree/api/v1/orders/invalid_shipping_method.v1.rabl b/api/app/views/spree/api/v1/orders/invalid_shipping_method.v1.rabl new file mode 100644 index 00000000000..9876194d71b --- /dev/null +++ b/api/app/views/spree/api/v1/orders/invalid_shipping_method.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:errors) { [I18n.t(:invalid_shipping_method, scope: 'spree.api.order')] } diff --git a/api/app/views/spree/api/v1/orders/mine.v1.rabl b/api/app/views/spree/api/v1/orders/mine.v1.rabl new file mode 100644 index 00000000000..ce5284216be --- /dev/null +++ b/api/app/views/spree/api/v1/orders/mine.v1.rabl @@ -0,0 +1,9 @@ +object false + +child(@orders => :orders) do + extends 'spree/api/v1/orders/show' +end + +node(:count) { @orders.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @orders.total_pages } diff --git a/api/app/views/spree/api/v1/orders/order.v1.rabl b/api/app/views/spree/api/v1/orders/order.v1.rabl new file mode 100644 index 00000000000..fda367071c3 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/order.v1.rabl @@ -0,0 +1,10 @@ +cache [I18n.locale, root_object] +attributes *order_attributes +node(:display_item_total) { |o| o.display_item_total.to_s } +node(:total_quantity) { |o| o.line_items.sum(:quantity) } +node(:display_total) { |o| o.display_total.to_s } +node(:display_ship_total, &:display_ship_total) +node(:display_tax_total, &:display_tax_total) +node(:display_adjustment_total, &:display_adjustment_total) +node(:token, &:token) +node(:checkout_steps, &:checkout_steps) diff --git a/api/app/views/spree/api/v1/orders/payment.v1.rabl b/api/app/views/spree/api/v1/orders/payment.v1.rabl new file mode 100644 index 00000000000..0533ca838a0 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/payment.v1.rabl @@ -0,0 +1,3 @@ +child available_payment_methods: :payment_methods do + attributes :id, :name, :method_type +end diff --git a/api/app/views/spree/api/v1/orders/show.v1.rabl b/api/app/views/spree/api/v1/orders/show.v1.rabl new file mode 100644 index 00000000000..6ae5a97b3b4 --- /dev/null +++ b/api/app/views/spree/api/v1/orders/show.v1.rabl @@ -0,0 +1,51 @@ +object @order +extends 'spree/api/v1/orders/order' + +if lookup_context.find_all("spree/api/v1/orders/#{root_object.state}").present? + extends "spree/api/v1/orders/#{root_object.state}" +end + +child billing_address: :bill_address do + extends 'spree/api/v1/addresses/show' +end + +child shipping_address: :ship_address do + extends 'spree/api/v1/addresses/show' +end + +child line_items: :line_items do + extends 'spree/api/v1/line_items/show' +end + +child payments: :payments do + attributes *payment_attributes + + child payment_method: :payment_method do + attributes :id, :name + end + + child source: :source do + if @current_user_roles.include?('admin') + attributes *payment_source_attributes + [:gateway_customer_profile_id, :gateway_payment_profile_id] + else + attributes *payment_source_attributes + end + end +end + +child shipments: :shipments do + extends 'spree/api/v1/shipments/small' +end + +child adjustments: :adjustments do + extends 'spree/api/v1/adjustments/show' +end + +# Necessary for backend's order interface +node :permissions do + { can_update: current_ability.can?(:update, root_object) } +end + +child valid_credit_cards: :credit_cards do + extends 'spree/api/v1/credit_cards/show' +end diff --git a/api/app/views/spree/api/v1/payments/credit_over_limit.v1.rabl b/api/app/views/spree/api/v1/payments/credit_over_limit.v1.rabl new file mode 100644 index 00000000000..e6ca9336fa9 --- /dev/null +++ b/api/app/views/spree/api/v1/payments/credit_over_limit.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:credit_over_limit, limit: @payment.credit_allowed, scope: 'spree.api.payment') } diff --git a/api/app/views/spree/api/v1/payments/index.v1.rabl b/api/app/views/spree/api/v1/payments/index.v1.rabl new file mode 100644 index 00000000000..b816d9e47fc --- /dev/null +++ b/api/app/views/spree/api/v1/payments/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@payments => :payments) do + attributes *payment_attributes +end +node(:count) { @payments.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @payments.total_pages } diff --git a/api/app/views/spree/api/v1/payments/new.v1.rabl b/api/app/views/spree/api/v1/payments/new.v1.rabl new file mode 100644 index 00000000000..05d227cf83e --- /dev/null +++ b/api/app/views/spree/api/v1/payments/new.v1.rabl @@ -0,0 +1,5 @@ +object false +node(:attributes) { [*payment_attributes] } +child @payment_methods => :payment_methods do + attributes *payment_method_attributes +end diff --git a/api/app/views/spree/api/v1/payments/show.v1.rabl b/api/app/views/spree/api/v1/payments/show.v1.rabl new file mode 100644 index 00000000000..e2f48cb940a --- /dev/null +++ b/api/app/views/spree/api/v1/payments/show.v1.rabl @@ -0,0 +1,2 @@ +object @payment +attributes *payment_attributes diff --git a/api/app/views/spree/api/v1/payments/update_forbidden.v1.rabl b/api/app/views/spree/api/v1/payments/update_forbidden.v1.rabl new file mode 100644 index 00000000000..9ce6b061fc6 --- /dev/null +++ b/api/app/views/spree/api/v1/payments/update_forbidden.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:update_forbidden, state: @payment.state, scope: 'spree.api.payment') } diff --git a/api/app/views/spree/api/v1/product_properties/index.v1.rabl b/api/app/views/spree/api/v1/product_properties/index.v1.rabl new file mode 100644 index 00000000000..2a04d57282c --- /dev/null +++ b/api/app/views/spree/api/v1/product_properties/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@product_properties => :product_properties) do + attributes *product_property_attributes +end +node(:count) { @product_properties.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @product_properties.total_pages } diff --git a/api/app/views/spree/api/v1/product_properties/new.v1.rabl b/api/app/views/spree/api/v1/product_properties/new.v1.rabl new file mode 100644 index 00000000000..37067119816 --- /dev/null +++ b/api/app/views/spree/api/v1/product_properties/new.v1.rabl @@ -0,0 +1,2 @@ +node(:attributes) { [*product_property_attributes] } +node(:required_attributes) { [] } diff --git a/api/app/views/spree/api/v1/product_properties/show.v1.rabl b/api/app/views/spree/api/v1/product_properties/show.v1.rabl new file mode 100644 index 00000000000..12e24137b53 --- /dev/null +++ b/api/app/views/spree/api/v1/product_properties/show.v1.rabl @@ -0,0 +1,2 @@ +object @product_property +attributes *product_property_attributes diff --git a/api/app/views/spree/api/v1/products/index.v1.rabl b/api/app/views/spree/api/v1/products/index.v1.rabl new file mode 100644 index 00000000000..faca145d230 --- /dev/null +++ b/api/app/views/spree/api/v1/products/index.v1.rabl @@ -0,0 +1,9 @@ +object false +node(:count) { @products.count } +node(:total_count) { @products.total_count } +node(:current_page) { params[:page] ? params[:page].to_i : 1 } +node(:per_page) { params[:per_page].try(:to_i) || Kaminari.config.default_per_page } +node(:pages) { @products.total_pages } +child(@products => :products) do + extends 'spree/api/v1/products/show' +end diff --git a/api/app/views/spree/api/v1/products/new.v1.rabl b/api/app/views/spree/api/v1/products/new.v1.rabl new file mode 100644 index 00000000000..71861f91837 --- /dev/null +++ b/api/app/views/spree/api/v1/products/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*product_attributes] } +node(:required_attributes) { required_fields_for(Spree::Product) } diff --git a/api/app/views/spree/api/v1/products/product.v1.rabl b/api/app/views/spree/api/v1/products/product.v1.rabl new file mode 100644 index 00000000000..ba18e3f709f --- /dev/null +++ b/api/app/views/spree/api/v1/products/product.v1.rabl @@ -0,0 +1 @@ +attributes *product_attributes diff --git a/api/app/views/spree/api/v1/products/show.v1.rabl b/api/app/views/spree/api/v1/products/show.v1.rabl new file mode 100644 index 00000000000..b6d2472ced3 --- /dev/null +++ b/api/app/views/spree/api/v1/products/show.v1.rabl @@ -0,0 +1,32 @@ +object @product +cache [I18n.locale, @current_user_roles.include?('admin'), current_currency, root_object] + +attributes *product_attributes + +node(:display_price) { |p| p.display_price.to_s } +node(:has_variants, &:has_variants?) +node(:taxon_ids, &:taxon_ids) + +child master: :master do + extends 'spree/api/v1/variants/small' +end + +child variants: :variants do + extends 'spree/api/v1/variants/small' +end + +child option_types: :option_types do + attributes *option_type_attributes +end + +child product_properties: :product_properties do + attributes *product_property_attributes +end + +child classifications: :classifications do + attributes :taxon_id, :position + + child(:taxon) do + extends 'spree/api/v1/taxons/show' + end +end diff --git a/api/app/views/spree/api/v1/promotions/handler.v1.rabl b/api/app/views/spree/api/v1/promotions/handler.v1.rabl new file mode 100644 index 00000000000..e08a89b08ee --- /dev/null +++ b/api/app/views/spree/api/v1/promotions/handler.v1.rabl @@ -0,0 +1,5 @@ +object false +node(:success) { @handler.success } +node(:error) { @handler.error } +node(:successful) { @handler.successful? } +node(:status_code) { @handler.status_code } diff --git a/api/app/views/spree/api/v1/promotions/show.v1.rabl b/api/app/views/spree/api/v1/promotions/show.v1.rabl new file mode 100644 index 00000000000..90a577d22ab --- /dev/null +++ b/api/app/views/spree/api/v1/promotions/show.v1.rabl @@ -0,0 +1,2 @@ +object @promotion +attributes *promotion_attributes diff --git a/api/app/views/spree/api/v1/properties/index.v1.rabl b/api/app/views/spree/api/v1/properties/index.v1.rabl new file mode 100644 index 00000000000..7a8ca7223c1 --- /dev/null +++ b/api/app/views/spree/api/v1/properties/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@properties => :properties) do + attributes *property_attributes +end +node(:count) { @properties.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @properties.total_pages } diff --git a/api/app/views/spree/api/v1/properties/new.v1.rabl b/api/app/views/spree/api/v1/properties/new.v1.rabl new file mode 100644 index 00000000000..76f03444e8e --- /dev/null +++ b/api/app/views/spree/api/v1/properties/new.v1.rabl @@ -0,0 +1,2 @@ +node(:attributes) { [*property_attributes] } +node(:required_attributes) { [] } diff --git a/api/app/views/spree/api/v1/properties/show.v1.rabl b/api/app/views/spree/api/v1/properties/show.v1.rabl new file mode 100644 index 00000000000..08cfbe5e9b6 --- /dev/null +++ b/api/app/views/spree/api/v1/properties/show.v1.rabl @@ -0,0 +1,2 @@ +object @property +attributes *property_attributes diff --git a/api/app/views/spree/api/v1/reimbursements/index.v1.rabl b/api/app/views/spree/api/v1/reimbursements/index.v1.rabl new file mode 100644 index 00000000000..b3d47cf0503 --- /dev/null +++ b/api/app/views/spree/api/v1/reimbursements/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@collection => :reimbursements) do + attributes *reimbursement_attributes +end +node(:count) { @collection.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @collection.total_pages } diff --git a/api/app/views/spree/api/v1/return_authorizations/index.v1.rabl b/api/app/views/spree/api/v1/return_authorizations/index.v1.rabl new file mode 100644 index 00000000000..1410d4de9d5 --- /dev/null +++ b/api/app/views/spree/api/v1/return_authorizations/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@return_authorizations => :return_authorizations) do + attributes *return_authorization_attributes +end +node(:count) { @return_authorizations.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @return_authorizations.total_pages } diff --git a/api/app/views/spree/api/v1/return_authorizations/new.v1.rabl b/api/app/views/spree/api/v1/return_authorizations/new.v1.rabl new file mode 100644 index 00000000000..362fa8ac9de --- /dev/null +++ b/api/app/views/spree/api/v1/return_authorizations/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*return_authorization_attributes] } +node(:required_attributes) { required_fields_for(Spree::ReturnAuthorization) } diff --git a/api/app/views/spree/api/v1/return_authorizations/show.v1.rabl b/api/app/views/spree/api/v1/return_authorizations/show.v1.rabl new file mode 100644 index 00000000000..72bfb859110 --- /dev/null +++ b/api/app/views/spree/api/v1/return_authorizations/show.v1.rabl @@ -0,0 +1,2 @@ +object @return_authorization +attributes *return_authorization_attributes diff --git a/api/app/views/spree/api/v1/shared/stock_location_required.v1.rabl b/api/app/views/spree/api/v1/shared/stock_location_required.v1.rabl new file mode 100644 index 00000000000..330bee5df9f --- /dev/null +++ b/api/app/views/spree/api/v1/shared/stock_location_required.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:stock_location_required, scope: 'spree.api') } diff --git a/api/app/views/spree/api/v1/shipments/big.v1.rabl b/api/app/views/spree/api/v1/shipments/big.v1.rabl new file mode 100644 index 00000000000..7776810a7c4 --- /dev/null +++ b/api/app/views/spree/api/v1/shipments/big.v1.rabl @@ -0,0 +1,48 @@ +object @shipment +cache @shipment +attributes *shipment_attributes + +child selected_shipping_rate: :selected_shipping_rate do + extends 'spree/api/v1/shipping_rates/show' +end + +child inventory_units: :inventory_units do + object @inventory_unit + attributes *inventory_unit_attributes + + child :variant do + extends 'spree/api/v1/variants/small' + attributes :product_id + child(images: :images) { extends 'spree/api/v1/images/show' } + end + + child :line_item do + attributes *line_item_attributes + node(:single_display_amount) { |li| li.single_display_amount.to_s } + node(:display_amount) { |li| li.display_amount.to_s } + node(:total, &:total) + end +end + +child order: :order do + extends 'spree/api/v1/orders/order' + + child billing_address: :bill_address do + extends 'spree/api/v1/addresses/show' + end + + child shipping_address: :ship_address do + extends 'spree/api/v1/addresses/show' + end + + child adjustments: :adjustments do + extends 'spree/api/v1/adjustments/show' + end + + child payments: :payments do + attributes :id, :amount, :display_amount, :state + child payment_method: :payment_method do + attributes :id, :name + end + end +end diff --git a/api/app/views/spree/api/v1/shipments/cannot_ready_shipment.v1.rabl b/api/app/views/spree/api/v1/shipments/cannot_ready_shipment.v1.rabl new file mode 100644 index 00000000000..8481b710c85 --- /dev/null +++ b/api/app/views/spree/api/v1/shipments/cannot_ready_shipment.v1.rabl @@ -0,0 +1,2 @@ +object false +node(:error) { I18n.t(:cannot_ready, scope: 'spree.api.shipment') } diff --git a/api/app/views/spree/api/v1/shipments/mine.v1.rabl b/api/app/views/spree/api/v1/shipments/mine.v1.rabl new file mode 100644 index 00000000000..8a4b5e83b7f --- /dev/null +++ b/api/app/views/spree/api/v1/shipments/mine.v1.rabl @@ -0,0 +1,9 @@ +object false + +node(:count) { @shipments.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @shipments.total_pages } + +child(@shipments => :shipments) do + extends 'spree/api/v1/shipments/big' +end diff --git a/api/app/views/spree/api/v1/shipments/show.v1.rabl b/api/app/views/spree/api/v1/shipments/show.v1.rabl new file mode 100644 index 00000000000..f9b069fc88c --- /dev/null +++ b/api/app/views/spree/api/v1/shipments/show.v1.rabl @@ -0,0 +1,32 @@ +object @shipment +cache [I18n.locale, root_object] +attributes *shipment_attributes +node(:order_id) { |shipment| shipment.order.number } +node(:stock_location_name) { |shipment| shipment.stock_location.name } + +child shipping_rates: :shipping_rates do + extends 'spree/api/v1/shipping_rates/show' +end + +child selected_shipping_rate: :selected_shipping_rate do + extends 'spree/api/v1/shipping_rates/show' +end + +child shipping_methods: :shipping_methods do + attributes :id, :name, :tracking_url + child zones: :zones do + attributes :id, :name, :description + end + + child shipping_categories: :shipping_categories do + attributes :id, :name + end +end + +child manifest: :manifest do + child variant: :variant do + extends 'spree/api/v1/variants/small' + end + node(:quantity, &:quantity) + node(:states, &:states) +end diff --git a/api/app/views/spree/api/v1/shipments/small.v1.rabl b/api/app/views/spree/api/v1/shipments/small.v1.rabl new file mode 100644 index 00000000000..f826dd6de92 --- /dev/null +++ b/api/app/views/spree/api/v1/shipments/small.v1.rabl @@ -0,0 +1,37 @@ +object @shipment +cache [I18n.locale, 'small_shipment', root_object] + +attributes *shipment_attributes +node(:order_id) { |shipment| shipment.order.number } +node(:stock_location_name) { |shipment| shipment.stock_location.name } + +child shipping_rates: :shipping_rates do + extends 'spree/api/v1/shipping_rates/show' +end + +child selected_shipping_rate: :selected_shipping_rate do + extends 'spree/api/v1/shipping_rates/show' +end + +child shipping_methods: :shipping_methods do + attributes :id, :code, :name + child zones: :zones do + attributes :id, :name, :description + end + + child shipping_categories: :shipping_categories do + attributes :id, :name + end +end + +child manifest: :manifest do + glue(:variant) do + attribute id: :variant_id + end + node(:quantity, &:quantity) + node(:states, &:states) +end + +child adjustments: :adjustments do + extends 'spree/api/v1/adjustments/show' +end diff --git a/api/app/views/spree/api/v1/shipping_rates/show.v1.rabl b/api/app/views/spree/api/v1/shipping_rates/show.v1.rabl new file mode 100644 index 00000000000..6b06d3102d3 --- /dev/null +++ b/api/app/views/spree/api/v1/shipping_rates/show.v1.rabl @@ -0,0 +1,2 @@ +attributes :id, :name, :cost, :selected, :shipping_method_id, :shipping_method_code +node(:display_cost) { |sr| sr.display_cost.to_s } diff --git a/api/app/views/spree/api/v1/states/index.v1.rabl b/api/app/views/spree/api/v1/states/index.v1.rabl new file mode 100644 index 00000000000..5ffad7024d1 --- /dev/null +++ b/api/app/views/spree/api/v1/states/index.v1.rabl @@ -0,0 +1,12 @@ +object false +node(:states_required) { @country.states_required } if @country + +child(@states => :states) do + attributes *state_attributes +end + +if @states.respond_to?(:total_pages) + node(:count) { @states.count } + node(:current_page) { params[:page].try(:to_i) || 1 } + node(:pages) { @states.total_pages } +end diff --git a/api/app/views/spree/api/v1/states/show.v1.rabl b/api/app/views/spree/api/v1/states/show.v1.rabl new file mode 100644 index 00000000000..3b7533d511b --- /dev/null +++ b/api/app/views/spree/api/v1/states/show.v1.rabl @@ -0,0 +1,2 @@ +object @state +attributes *state_attributes diff --git a/api/app/views/spree/api/v1/stock_items/index.v1.rabl b/api/app/views/spree/api/v1/stock_items/index.v1.rabl new file mode 100644 index 00000000000..350cb1e609d --- /dev/null +++ b/api/app/views/spree/api/v1/stock_items/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@stock_items => :stock_items) do + extends 'spree/api/v1/stock_items/show' +end +node(:count) { @stock_items.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @stock_items.total_pages } diff --git a/api/app/views/spree/api/v1/stock_items/show.v1.rabl b/api/app/views/spree/api/v1/stock_items/show.v1.rabl new file mode 100644 index 00000000000..1047974e9c5 --- /dev/null +++ b/api/app/views/spree/api/v1/stock_items/show.v1.rabl @@ -0,0 +1,5 @@ +object @stock_item +attributes *stock_item_attributes +child(:variant) do + extends 'spree/api/v1/variants/small' +end diff --git a/api/app/views/spree/api/v1/stock_locations/index.v1.rabl b/api/app/views/spree/api/v1/stock_locations/index.v1.rabl new file mode 100644 index 00000000000..bbd6cd04610 --- /dev/null +++ b/api/app/views/spree/api/v1/stock_locations/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@stock_locations => :stock_locations) do + extends 'spree/api/v1/stock_locations/show' +end +node(:count) { @stock_locations.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @stock_locations.total_pages } diff --git a/api/app/views/spree/api/v1/stock_locations/show.v1.rabl b/api/app/views/spree/api/v1/stock_locations/show.v1.rabl new file mode 100644 index 00000000000..f5c74e3b96d --- /dev/null +++ b/api/app/views/spree/api/v1/stock_locations/show.v1.rabl @@ -0,0 +1,8 @@ +object @stock_location +attributes *stock_location_attributes +child(:country) do |_address| + attributes *country_attributes +end +child(:state) do |_address| + attributes *state_attributes +end diff --git a/api/app/views/spree/api/v1/stock_movements/index.v1.rabl b/api/app/views/spree/api/v1/stock_movements/index.v1.rabl new file mode 100644 index 00000000000..6e098424c4f --- /dev/null +++ b/api/app/views/spree/api/v1/stock_movements/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@stock_movements => :stock_movements) do + extends 'spree/api/v1/stock_movements/show' +end +node(:count) { @stock_movements.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @stock_movements.total_pages } diff --git a/api/app/views/spree/api/v1/stock_movements/show.v1.rabl b/api/app/views/spree/api/v1/stock_movements/show.v1.rabl new file mode 100644 index 00000000000..8c24b4bae26 --- /dev/null +++ b/api/app/views/spree/api/v1/stock_movements/show.v1.rabl @@ -0,0 +1,5 @@ +object @stock_movement +attributes *stock_movement_attributes +child :stock_item do + extends 'spree/api/v1/stock_items/show' +end diff --git a/api/app/views/spree/api/v1/stores/index.v1.rabl b/api/app/views/spree/api/v1/stores/index.v1.rabl new file mode 100644 index 00000000000..ae547356bb7 --- /dev/null +++ b/api/app/views/spree/api/v1/stores/index.v1.rabl @@ -0,0 +1,4 @@ +object false +child(@stores => :stores) do + attributes *store_attributes +end diff --git a/api/app/views/spree/api/v1/stores/show.v1.rabl b/api/app/views/spree/api/v1/stores/show.v1.rabl new file mode 100644 index 00000000000..a0e1941b723 --- /dev/null +++ b/api/app/views/spree/api/v1/stores/show.v1.rabl @@ -0,0 +1,2 @@ +object @store +attributes *store_attributes diff --git a/api/app/views/spree/api/v1/tags/index.v1.rabl b/api/app/views/spree/api/v1/tags/index.v1.rabl new file mode 100644 index 00000000000..339df7ea387 --- /dev/null +++ b/api/app/views/spree/api/v1/tags/index.v1.rabl @@ -0,0 +1,9 @@ +object false +node(:count) { @tags.count } +node(:total_count) { @tags.total_count } +node(:current_page) { params[:page] ? params[:page].to_i : 1 } +node(:per_page) { params[:per_page] || Kaminari.config.default_per_page } +node(:pages) { @tags.total_pages } +child(@tags => :tags) do + attributes :name, :id +end diff --git a/api/app/views/spree/api/v1/taxonomies/index.v1.rabl b/api/app/views/spree/api/v1/taxonomies/index.v1.rabl new file mode 100644 index 00000000000..0febb17df5a --- /dev/null +++ b/api/app/views/spree/api/v1/taxonomies/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@taxonomies => :taxonomies) do + extends 'spree/api/v1/taxonomies/show' +end +node(:count) { @taxonomies.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @taxonomies.total_pages } diff --git a/api/app/views/spree/api/v1/taxonomies/jstree.rabl b/api/app/views/spree/api/v1/taxonomies/jstree.rabl new file mode 100644 index 00000000000..727debf2dc7 --- /dev/null +++ b/api/app/views/spree/api/v1/taxonomies/jstree.rabl @@ -0,0 +1,7 @@ +object false +node(:data) { @taxonomy.root.name } +node(:attr) do + { id: @taxonomy.root.id, + name: @taxonomy.root.name } +end +node(:state) { 'closed' } diff --git a/api/app/views/spree/api/v1/taxonomies/nested.v1.rabl b/api/app/views/spree/api/v1/taxonomies/nested.v1.rabl new file mode 100644 index 00000000000..1314df59745 --- /dev/null +++ b/api/app/views/spree/api/v1/taxonomies/nested.v1.rabl @@ -0,0 +1,11 @@ +attributes *taxonomy_attributes + +child root: :root do + attributes *taxon_attributes + + child children: :taxons do + attributes *taxon_attributes + + extends 'spree/api/v1/taxons/taxons' + end +end diff --git a/api/app/views/spree/api/v1/taxonomies/new.v1.rabl b/api/app/views/spree/api/v1/taxonomies/new.v1.rabl new file mode 100644 index 00000000000..a3dd32d4efd --- /dev/null +++ b/api/app/views/spree/api/v1/taxonomies/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*taxonomy_attributes] } +node(:required_attributes) { required_fields_for(Spree::Taxonomy) } diff --git a/api/app/views/spree/api/v1/taxonomies/show.v1.rabl b/api/app/views/spree/api/v1/taxonomies/show.v1.rabl new file mode 100644 index 00000000000..a99bddcfdd1 --- /dev/null +++ b/api/app/views/spree/api/v1/taxonomies/show.v1.rabl @@ -0,0 +1,15 @@ +object @taxonomy + +if params[:set] == 'nested' + extends 'spree/api/v1/taxonomies/nested' +else + attributes *taxonomy_attributes + + child root: :root do + attributes *taxon_attributes + + child children: :taxons do + attributes *taxon_attributes + end + end +end diff --git a/api/app/views/spree/api/v1/taxons/index.v1.rabl b/api/app/views/spree/api/v1/taxons/index.v1.rabl new file mode 100644 index 00000000000..6256e952b1a --- /dev/null +++ b/api/app/views/spree/api/v1/taxons/index.v1.rabl @@ -0,0 +1,10 @@ +object false +node(:count) { @taxons.count } +node(:total_count) { @taxons.total_count } +node(:current_page) { params[:page] ? params[:page].to_i : 1 } +node(:per_page) { params[:per_page].try(:to_i) || Kaminari.config.default_per_page } +node(:pages) { @taxons.total_pages } +child @taxons => :taxons do + attributes *taxon_attributes + extends 'spree/api/v1/taxons/taxons' unless params[:without_children] +end diff --git a/api/app/views/spree/api/v1/taxons/jstree.rabl b/api/app/views/spree/api/v1/taxons/jstree.rabl new file mode 100644 index 00000000000..fdfdaae5f51 --- /dev/null +++ b/api/app/views/spree/api/v1/taxons/jstree.rabl @@ -0,0 +1,7 @@ +collection @taxon.children, object_root: false +node(:data, &:name) +node(:attr) do |taxon| + { id: taxon.id, + name: taxon.name } +end +node(:state) { 'closed' } diff --git a/api/app/views/spree/api/v1/taxons/new.v1.rabl b/api/app/views/spree/api/v1/taxons/new.v1.rabl new file mode 100644 index 00000000000..f85dbece9ea --- /dev/null +++ b/api/app/views/spree/api/v1/taxons/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*taxon_attributes] } +node(:required_attributes) { required_fields_for(Spree::Taxon) } diff --git a/api/app/views/spree/api/v1/taxons/show.v1.rabl b/api/app/views/spree/api/v1/taxons/show.v1.rabl new file mode 100644 index 00000000000..95f485715f7 --- /dev/null +++ b/api/app/views/spree/api/v1/taxons/show.v1.rabl @@ -0,0 +1,6 @@ +object @taxon +attributes *taxon_attributes + +child children: :taxons do + attributes *taxon_attributes +end diff --git a/api/app/views/spree/api/v1/taxons/taxons.v1.rabl b/api/app/views/spree/api/v1/taxons/taxons.v1.rabl new file mode 100644 index 00000000000..3470decfc1f --- /dev/null +++ b/api/app/views/spree/api/v1/taxons/taxons.v1.rabl @@ -0,0 +1,5 @@ +attributes *taxon_attributes + +node :taxons do |t| + t.children.map { |c| partial('spree/api/v1/taxons/taxons', object: c) } +end diff --git a/api/app/views/spree/api/v1/users/index.v1.rabl b/api/app/views/spree/api/v1/users/index.v1.rabl new file mode 100644 index 00000000000..f481400797c --- /dev/null +++ b/api/app/views/spree/api/v1/users/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@users => :users) do + extends 'spree/api/v1/users/show' +end +node(:count) { @users.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @users.total_pages } diff --git a/api/app/views/spree/api/v1/users/new.v1.rabl b/api/app/views/spree/api/v1/users/new.v1.rabl new file mode 100644 index 00000000000..f3e36dd7d33 --- /dev/null +++ b/api/app/views/spree/api/v1/users/new.v1.rabl @@ -0,0 +1,3 @@ +object false +node(:attributes) { [*user_attributes] } +node(:required_attributes) { required_fields_for(Spree.user_class) } diff --git a/api/app/views/spree/api/v1/users/show.v1.rabl b/api/app/views/spree/api/v1/users/show.v1.rabl new file mode 100644 index 00000000000..b5f680a93aa --- /dev/null +++ b/api/app/views/spree/api/v1/users/show.v1.rabl @@ -0,0 +1,11 @@ +object @user +cache [I18n.locale, root_object] + +attributes *user_attributes +child(bill_address: :bill_address) do + extends 'spree/api/v1/addresses/show' +end + +child(ship_address: :ship_address) do + extends 'spree/api/v1/addresses/show' +end diff --git a/api/app/views/spree/api/v1/variants/big.v1.rabl b/api/app/views/spree/api/v1/variants/big.v1.rabl new file mode 100644 index 00000000000..718422228cc --- /dev/null +++ b/api/app/views/spree/api/v1/variants/big.v1.rabl @@ -0,0 +1,14 @@ +object @variant + +cache [I18n.locale, @current_user_roles.include?('admin'), 'big_variant', root_object] + +extends 'spree/api/v1/variants/small' + +child(stock_items: :stock_items) do + attributes :id, :count_on_hand, :stock_location_id, :backorderable + attribute available?: :available + + glue(:stock_location) do + attribute name: :stock_location_name + end +end diff --git a/api/app/views/spree/api/v1/variants/index.v1.rabl b/api/app/views/spree/api/v1/variants/index.v1.rabl new file mode 100644 index 00000000000..3b941ebb29e --- /dev/null +++ b/api/app/views/spree/api/v1/variants/index.v1.rabl @@ -0,0 +1,9 @@ +object false +node(:count) { @variants.count } +node(:total_count) { @variants.total_count } +node(:current_page) { params[:page] ? params[:page].to_i : 1 } +node(:pages) { @variants.total_pages } + +child(@variants => :variants) do + extends 'spree/api/v1/variants/big' +end diff --git a/api/app/views/spree/api/v1/variants/new.v1.rabl b/api/app/views/spree/api/v1/variants/new.v1.rabl new file mode 100644 index 00000000000..26cf41db3b2 --- /dev/null +++ b/api/app/views/spree/api/v1/variants/new.v1.rabl @@ -0,0 +1,2 @@ +node(:attributes) { [*variant_attributes] } +node(:required_attributes) { [] } diff --git a/api/app/views/spree/api/v1/variants/show.v1.rabl b/api/app/views/spree/api/v1/variants/show.v1.rabl new file mode 100644 index 00000000000..0d4d5942171 --- /dev/null +++ b/api/app/views/spree/api/v1/variants/show.v1.rabl @@ -0,0 +1,3 @@ +object @variant +cache [I18n.locale, @current_user_roles.include?('admin'), 'show', root_object] +extends 'spree/api/v1/variants/big' diff --git a/api/app/views/spree/api/v1/variants/small.v1.rabl b/api/app/views/spree/api/v1/variants/small.v1.rabl new file mode 100644 index 00000000000..ab80fc636f4 --- /dev/null +++ b/api/app/views/spree/api/v1/variants/small.v1.rabl @@ -0,0 +1,18 @@ +cache [I18n.locale, @current_user_roles.include?('admin'), 'small_variant', root_object] + +attributes *variant_attributes + +node(:display_price) { |p| p.display_price.to_s } +node(:options_text, &:options_text) +node(:track_inventory, &:should_track_inventory?) +node(:in_stock, &:in_stock?) +node(:is_backorderable, &:is_backorderable?) +node(:is_orderable) { |v| v.is_backorderable? || v.in_stock? } +node(:total_on_hand, &:total_on_hand) +node(:is_destroyed, &:destroyed?) + +child option_values: :option_values do + attributes *option_value_attributes +end + +child(images: :images) { extends 'spree/api/v1/images/show' } diff --git a/api/app/views/spree/api/v1/zones/index.v1.rabl b/api/app/views/spree/api/v1/zones/index.v1.rabl new file mode 100644 index 00000000000..633f9181373 --- /dev/null +++ b/api/app/views/spree/api/v1/zones/index.v1.rabl @@ -0,0 +1,7 @@ +object false +child(@zones => :zones) do + extends 'spree/api/v1/zones/show' +end +node(:count) { @zones.count } +node(:current_page) { params[:page].try(:to_i) || 1 } +node(:pages) { @zones.total_pages } diff --git a/api/app/views/spree/api/v1/zones/show.v1.rabl b/api/app/views/spree/api/v1/zones/show.v1.rabl new file mode 100644 index 00000000000..eb66574ed4a --- /dev/null +++ b/api/app/views/spree/api/v1/zones/show.v1.rabl @@ -0,0 +1,6 @@ +object @zone +attributes :id, :name, :description + +child zone_members: :zone_members do + attributes :id, :name, :zoneable_type, :zoneable_id +end diff --git a/api/config/initializers/doorkeeper.rb b/api/config/initializers/doorkeeper.rb new file mode 100644 index 00000000000..3f6c3347993 --- /dev/null +++ b/api/config/initializers/doorkeeper.rb @@ -0,0 +1,20 @@ +Doorkeeper.configure do + orm :active_record + use_refresh_token + api_only + + resource_owner_authenticator { current_spree_user } + + resource_owner_from_credentials do + user = Spree.user_class.find_for_database_authentication(email: params[:username]) + user if user&.valid_for_authentication? { user.valid_password?(params[:password]) } + end + + admin_authenticator do |routes| + current_spree_user&.has_spree_role?('admin') || redirect_to(routes.root_url) + end + + grant_flows %w(password) + + access_token_methods :from_bearer_authorization, :from_access_token_param +end diff --git a/api/config/initializers/metal_load_paths.rb b/api/config/initializers/metal_load_paths.rb new file mode 100644 index 00000000000..a21345f6f1e --- /dev/null +++ b/api/config/initializers/metal_load_paths.rb @@ -0,0 +1 @@ +Spree::Api::BaseController.append_view_path(ApplicationController.view_paths) diff --git a/api/config/initializers/user_class_extensions.rb b/api/config/initializers/user_class_extensions.rb new file mode 100644 index 00000000000..15f34b33274 --- /dev/null +++ b/api/config/initializers/user_class_extensions.rb @@ -0,0 +1,7 @@ +# Ensure that Spree.user_class includes the UserApiMethods concern + +Spree::Core::Engine.config.to_prepare do + if Spree.user_class && !Spree.user_class.included_modules.include?(Spree::UserApiMethods) + Spree.user_class.include Spree::UserApiMethods + end +end diff --git a/api/config/locales/en.yml b/api/config/locales/en.yml new file mode 100644 index 00000000000..710f826c271 --- /dev/null +++ b/api/config/locales/en.yml @@ -0,0 +1,35 @@ +en: + spree: + api: + must_specify_api_key: "You must specify an API key." + invalid_api_key: "Invalid API key (%{key}) specified." + unauthorized: "You are not authorized to perform that action." + invalid_resource: "Invalid resource. Please fix errors and try again." + resource_not_found: "The resource you were looking for could not be found." + gateway_error: "There was a problem with the payment gateway: %{text}" + access: "API Access" + key: "Key" + clear_key: "Clear key" + regenerate_key: "Regenerate Key" + no_key: "No key" + generate_key: "Generate API key" + key_generated: "Key generated" + key_cleared: "Key cleared" + order: + could_not_transition: "The order could not be transitioned. Please fix the errors and try again." + invalid_shipping_method: "Invalid shipping method specified." + insufficient_quantity: An item in your cart has become unavailable. + payment: + credit_over_limit: "This payment can only be credited up to %{limit}. Please specify an amount less than or equal to this number." + update_forbidden: "This payment cannot be updated because it is %{state}." + shipment: + cannot_ready: "Cannot ready shipment." + stock_location_required: "A stock_location_id parameter must be provided in order to retrieve stock movements." + invalid_taxonomy_id: "Invalid taxonomy id." + shipment_transfer_errors_occured: "Following errors occured while attempting this action:" + negative_quantity: "quantity is negative" + wrong_shipment_target: "target shipment is the same as original shipment" + + v2: + cart: + wrong_quantity: "Quantity has to be greater than 0" diff --git a/api/config/routes.rb b/api/config/routes.rb new file mode 100644 index 00000000000..51396abe62f --- /dev/null +++ b/api/config/routes.rb @@ -0,0 +1,190 @@ +spree_path = Rails.application.routes.url_helpers.try(:spree_path, trailing_slash: true) || '/' + +Rails.application.routes.draw do + use_doorkeeper scope: "#{spree_path}/spree_oauth" +end + +Spree::Core::Engine.add_routes do + namespace :api, defaults: { format: 'json' } do + namespace :v1 do + resources :promotions, only: [:show] + + resources :customer_returns, only: [:index] + resources :reimbursements, only: [:index] + + resources :products do + resources :images + resources :variants + resources :product_properties + end + + concern :order_routes do + member do + put :approve + put :cancel + put :empty + put :apply_coupon_code + end + + resources :line_items + resources :payments do + member do + put :authorize + put :capture + put :purchase + put :void + put :credit + end + end + + resources :addresses, only: [:show, :update] + + resources :return_authorizations do + member do + put :add + put :cancel + put :receive + end + end + end + + resources :checkouts, only: [:update], concerns: :order_routes do + member do + put :next + put :advance + end + end + + resources :variants do + resources :images + end + + resources :option_types do + resources :option_values + end + resources :option_values + + resources :option_values, only: :index + + get '/orders/mine', to: 'orders#mine', as: 'my_orders' + get '/orders/current', to: 'orders#current', as: 'current_order' + + resources :orders, concerns: :order_routes do + put :remove_coupon_code, on: :member + end + + resources :zones + resources :countries, only: [:index, :show] do + resources :states, only: [:index, :show] + end + + resources :shipments, only: [:create, :update] do + collection do + post 'transfer_to_location' + post 'transfer_to_shipment' + get :mine + end + + member do + put :ready + put :ship + put :add + put :remove + end + end + resources :states, only: [:index, :show] + + resources :taxonomies do + member do + get :jstree + end + resources :taxons do + member do + get :jstree + end + end + end + + resources :taxons, only: [:index] + + resources :inventory_units, only: [:show, :update] + + resources :users do + resources :credit_cards, only: [:index] + end + + resources :properties + resources :stock_locations do + resources :stock_movements + resources :stock_items + end + + resources :stock_items, only: [:index, :update, :destroy] + resources :stores + + resources :tags, only: :index + + put '/classifications', to: 'classifications#update', as: :classifications + get '/taxons/products', to: 'taxons#products', as: :taxon_products + end + + namespace :v2 do + namespace :storefront do + resource :cart, controller: :cart, only: %i[show create] do + post :add_item + patch :empty + delete 'remove_line_item/:line_item_id', to: 'cart#remove_line_item', as: :cart_remove_line_item + patch :set_quantity + patch :apply_coupon_code + delete 'remove_coupon_code/:coupon_code', to: 'cart#remove_coupon_code', as: :cart_remove_coupon_code + end + + resource :checkout, controller: :checkout, only: %i[update] do + patch :next + patch :advance + patch :complete + post :add_store_credit + post :remove_store_credit + get :payment_methods + get :shipping_rates + end + + resource :account, controller: :account, only: %i[show] + + namespace :account do + resources :credit_cards, controller: :credit_cards, only: %i[index show] + resources :orders, controller: :orders, only: %i[index show] + end + + resources :countries, only: %i[index] + get '/countries/:iso', to: 'countries#show', as: :country + resources :products, only: %i[index show] + resources :taxons, only: %i[index show] + end + end + + get '/404', to: 'errors#render_404' + + match 'v:api/*path', to: redirect { |params, request| + format = ".#{params[:format]}" unless params[:format].blank? + query = "?#{request.query_string}" unless request.query_string.blank? + + if request.path == "#{spree_path}api/v1/#{params[:path]}#{format}#{query}" + "#{spree_path}api/404" + else + "#{spree_path}api/v1/#{params[:path]}#{format}#{query}" + end + }, via: [:get, :post, :put, :patch, :delete] + + match '*path', to: redirect { |params, request| + format = ".#{params[:format]}" unless params[:format].blank? + query = "?#{request.query_string}" unless request.query_string.blank? + + if request.path == "#{spree_path}api/v1/#{params[:path]}#{format}#{query}" + "#{spree_path}api/404" + else + "#{spree_path}api/v1/#{params[:path]}#{format}#{query}" + end + }, via: [:get, :post, :put, :patch, :delete] + end +end diff --git a/api/db/migrate/20100107141738_add_api_key_to_spree_users.rb b/api/db/migrate/20100107141738_add_api_key_to_spree_users.rb new file mode 100644 index 00000000000..e73d67b2765 --- /dev/null +++ b/api/db/migrate/20100107141738_add_api_key_to_spree_users.rb @@ -0,0 +1,7 @@ +class AddApiKeyToSpreeUsers < ActiveRecord::Migration[4.2] + def change + unless defined?(User) + add_column :spree_users, :api_key, :string, limit: 40 + end + end +end diff --git a/api/db/migrate/20120411123334_resize_api_key_field.rb b/api/db/migrate/20120411123334_resize_api_key_field.rb new file mode 100644 index 00000000000..23d520214fc --- /dev/null +++ b/api/db/migrate/20120411123334_resize_api_key_field.rb @@ -0,0 +1,7 @@ +class ResizeApiKeyField < ActiveRecord::Migration[4.2] + def change + unless defined?(User) + change_column :spree_users, :api_key, :string, limit: 48 + end + end +end diff --git a/api/db/migrate/20120530054546_rename_api_key_to_spree_api_key.rb b/api/db/migrate/20120530054546_rename_api_key_to_spree_api_key.rb new file mode 100644 index 00000000000..230a6209658 --- /dev/null +++ b/api/db/migrate/20120530054546_rename_api_key_to_spree_api_key.rb @@ -0,0 +1,7 @@ +class RenameApiKeyToSpreeApiKey < ActiveRecord::Migration[4.2] + def change + unless defined?(User) + rename_column :spree_users, :api_key, :spree_api_key + end + end +end diff --git a/api/db/migrate/20131017162334_add_index_to_user_spree_api_key.rb b/api/db/migrate/20131017162334_add_index_to_user_spree_api_key.rb new file mode 100644 index 00000000000..a1472bd5d54 --- /dev/null +++ b/api/db/migrate/20131017162334_add_index_to_user_spree_api_key.rb @@ -0,0 +1,7 @@ +class AddIndexToUserSpreeApiKey < ActiveRecord::Migration[4.2] + def change + unless defined?(User) + add_index :spree_users, :spree_api_key + end + end +end diff --git a/api/db/migrate/20180320110726_create_doorkeeper_tables.rb b/api/db/migrate/20180320110726_create_doorkeeper_tables.rb new file mode 100644 index 00000000000..aacc661ee67 --- /dev/null +++ b/api/db/migrate/20180320110726_create_doorkeeper_tables.rb @@ -0,0 +1,69 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration[5.1] + def change + create_table :spree_oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.boolean :confidential, null: false, default: true + t.timestamps null: false + end + + add_index :spree_oauth_applications, :uid, unique: true + + create_table :spree_oauth_access_grants do |t| + t.integer :resource_owner_id, null: false + t.references :application, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.datetime :created_at, null: false + t.datetime :revoked_at + t.string :scopes + end + + add_index :spree_oauth_access_grants, :token, unique: true + add_foreign_key( + :spree_oauth_access_grants, + :spree_oauth_applications, + column: :application_id + ) + + create_table :spree_oauth_access_tokens do |t| + t.integer :resource_owner_id + t.references :application + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.datetime :revoked_at + t.datetime :created_at, null: false + t.string :scopes + + # If there is a previous_refresh_token column, + # refresh tokens will be revoked after a related access token is used. + # If there is no previous_refresh_token column, + # previous tokens are revoked as soon as a new access token is created. + # Comment out this line if you'd rather have refresh tokens + # instantly revoked. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :spree_oauth_access_tokens, :token, unique: true + add_index :spree_oauth_access_tokens, :resource_owner_id + add_index :spree_oauth_access_tokens, :refresh_token, unique: true + add_foreign_key( + :spree_oauth_access_tokens, + :spree_oauth_applications, + column: :application_id + ) + end +end diff --git a/api/docs/oauth/index.yml b/api/docs/oauth/index.yml new file mode 100644 index 00000000000..d426a7704a0 --- /dev/null +++ b/api/docs/oauth/index.yml @@ -0,0 +1,77 @@ +openapi: 3.0.0 +servers: + - url: 'http://localhost:3000/spree_oauth' +info: + version: 1.0.0 + title: Spree OAuth 2.0 Authentication + description: >- + Spree uses the [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper) gem + for handling OAuth. This integration is built into `spree_api` gem. +paths: + '/token': + post: + description: >- + Creates or refreshes a Bearer token required to authorize calls API calls + tags: + - Token + operationId: Create or Refresh Token + responses: + '200': + description: Token was succesfully created or refreshed + content: + application/json: + schema: + $ref: '#/components/schemas/Token' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + oneOf: + - $ref: '#/components/schemas/CreateTokenBody' + - $ref: '#/components/schemas/RefreshTokenBody' +components: + schemas: + Token: + properties: + access_token: + type: string + example: 2480c16561d1391ea81ca5336b651e9a29f4524f6dee8c7f3f02a600159189c3 + token_type: + type: string + example: Bearer + default: Bearer + expires_in: + type: integer + example: 7200 + description: 'Time (in seconds) after which the access token will expire' + refresh_token: + type: string + example: f5d78642252eeb3f3001f67b196ac21a27afc030462a54060b0ebbdae2b8dc9c + created_at: + type: integer + example: 1539863418 + CreateTokenBody: + properties: + grant_type: + type: string + default: password + description: >- + Use `password` to create a token and `refresh_token` to refresh it + username: + type: string + description: User email address + example: 'spree@example.com' + password: + type: string + description: User password + example: 'spree123' + RefreshTokenBody: + properties: + grant_type: + type: string + default: refresh_token + refresh_token: + type: string + description: Rerefresh token obtained from the old created token + example: 27af95fd57a424e5d01aaf5eab1324a8d5c0ca57daf384fae39f811a5144330143301' diff --git a/api/docs/v2/storefront/index.yaml b/api/docs/v2/storefront/index.yaml new file mode 100644 index 00000000000..f1a27be6724 --- /dev/null +++ b/api/docs/v2/storefront/index.yaml @@ -0,0 +1,2547 @@ +openapi: 3.0.0 +servers: + - url: 'http://localhost:3000/api/v2/storefront' +info: + version: 2.0.0 + title: Storefront API + description: >- +

+ Storefront API v2 is a modern REST API based on the + JSON API spec + which provides you with necessary endpoints to build amazing user + intefaces either in JavaScript frameworks or native mobile libraries. +

+

+ Please read our introduction to the API v2 [insert link here] +

+

+ + Import this documentation to Postman + +

+paths: + '/account': + get: + description: >- + Returns current user information + tags: + - Account + operationId: 'Account Information' + parameters: + - $ref: '#/components/parameters/AccountIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + '403': + $ref: '#/components/responses/403Forbidden' + security: + - bearerAuth: [] + + '/account/credit_cards': + get: + description: >- + Returns a list of Credit Cards for the signed in User + tags: + - Account + operationId: 'Credit Cards list' + responses: + '200': + description: Listing user credit cards. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditCardList' + '403': + $ref: '#/components/responses/403Forbidden' + security: + - bearerAuth: [] + parameters: + - in: query + name: filter[payment_method_id] + schema: + type: integer + example: 2 + description: Filter based on payment method ID + - $ref: '#/components/parameters/CreditCardIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + '/account/credit_cards/default': + get: + description: >- + Return the User's default Credit Card + tags: + - Account + operationId: 'Default Credit Card' + responses: + '200': + description: Listing user default credit card. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditCard' + '403': + $ref: '#/components/responses/403Forbidden' + parameters: + - $ref: '#/components/parameters/CreditCardIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + security: + - bearerAuth: [] + '/account/orders': + get: + description: Returns Orders placed by the User. Only completed ones. + tags: + - Account + operationId: 'Completed Orders' + responses: + '200': + description: Listing user completed orders. + content: + application/json: + schema: + $ref: '#/components/schemas/CartList' + '403': + $ref: '#/components/responses/403Forbidden' + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + security: + - bearerAuth: [] + '/account/orders/{number}': + get: + description: >- + Return the User's completed Order + tags: + - Account + operationId: 'Completed User Order' + responses: + '200': + description: Listing user completed order. + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '403': + $ref: '#/components/responses/403Forbidden' + parameters: + - $ref: '#/components/parameters/OrderParam' + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + security: + - bearerAuth: [] + '/cart': + post: + description: >- +

Creates new Cart and returns it attributes.

+
+

+ token attribute, can be used for authorization of all operations + on this particular Cart and Checkout. +

+ tags: + - Cart + operationId: 'Create Cart' + responses: + '201': + description: Cart was successfully created + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + get: + description: Returns contents of the cart + tags: + - Cart + operationId: 'Get Cart' + responses: + '200': + description: Correct cart was returned + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + + '/cart/add_item': + post: + description: >- + Adds a Product Variant to the Cart +
+

+ Additional parameters passed in options hash + must be registered in config/initialiers/spree.rb, eg. +

+
+
+ Spree::PermittedAttributes.line_item_attributes << :foo +
+ tags: + - Cart + operationId: 'Add Item' + responses: + '200': + description: Item was added to the Cart successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + variant_id: + type: string + quantity: + type: integer + options: + type: object + description: >- + Additional custom options. + You need to add them via + `Spree::PermittedAttributes.line_item_attributes << :foo` + in `config/initialiers/spree.rb` + example: + variant_id: "1" + quantity: 5 + + '/cart/set_quantity': + patch: + description: Sets the quantity of a given line item. It has to be a positive integer greater than 0. + tags: + - Cart + operationId: 'Set Quantity' + responses: + '200': + description: New quantity has been successfully set for a requested Line Item + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Stock quantity is not sufficient for this request to be fulfielled + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + line_item_id: + type: string + quantity: + type: integer + example: + line_item_id: "1" + quantity: 5 + + '/cart/remove_line_item/{line_item_id}': + delete: + description: Removes Line Item from Cart + tags: + - Cart + operationId: 'Remove Line Item' + parameters: + - $ref: '#/components/parameters/LineItemId' + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Requested Line Item has been removed from the Cart + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + + '/cart/empty': + patch: + description: Empties the Cart + tags: + - Cart + operationId: 'Empty Cart' + responses: + '200': + description: Cart has been successfully emptied + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + + '/cart/apply_coupon_code': + patch: + description: Applies a coupon code to the Cart + tags: + - Cart + operationId: 'Apply Coupon Code' + responses: + '200': + description: Cuopon code was applied successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Coupon code couldn't be applied + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + coupon_code: + type: string + example: 'DISCOUNT10' + + '/cart/remove_coupon_code/{coupon_code}': + delete: + description: Removes a coupon code from the Cart + tags: + - Cart + operationId: 'Remove Coupon Code' + parameters: + - name: coupon_code + in: path + required: true + description: Coupon code applied to Order + schema: + type: string + example: 'DISCOUNT10' + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Coupon code was removed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Coupon code couldn't be removed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + security: + - orderToken: [] + - bearerAuth: [] + + '/checkout': + patch: + description: >- + Updates the Checkout +
+

+ You can run multiple Checkout updates with different data types. +

+

1. Update the Customer information

+
PATCH /checkout
+
+
+          order: {
+            email: 'john@snow.org',
+            bill_address_attributes: {
+              {
+                firstname: 'John',
+                lastname: 'Snow',
+                address1: '7735 Old Georgetown Road',
+                city: 'Bethesda',
+                phone: '3014445002',
+                zipcode: '20814',
+                state_name: 'MD',
+                country_iso: 'US'
+              }
+            },
+            ship_address_attributes: {
+              {
+                firstname: 'John',
+                lastname: 'Snow',
+                address1: '7735 Old Georgetown Road',
+                city: 'Bethesda',
+                phone: '3014445002',
+                zipcode: '20814',
+                state_name: 'MD',
+                country_iso: 'US'
+              }
+            }
+          }
+        
+

2. Fetch Shipping Rates

+
+        GET /checkout/shipping_rates
+        
+
+

+ Order can have multiple Shipments, eg. some Items will be shipped right away, + and some needs to be backordered. +

+

+ Each Shipment can have different Shipping Method/Rate selected. +

+ +

3. Select shipping method(s)

+
PATCH /checkout
+
+
+          order: {
+            shipments_attributes: {
+              '0' => { selected_shipping_rate_id: 1, id: 1}
+            }
+          }
+        
+
+

+ selected_shipping_rate_id is the ID of a Shipping Rate. + id is the ID of the Shipment itself. You can update multiple + Shipments at once. +

+

4. Add Payment Source(s)

+
PATCH /checkout
+
+
+          {
+            order: {
+              payments_attributes: [
+                {
+                  payment_method_id: 1
+                }
+              ]
+            },
+            payment_source: {
+              '1' => {
+                number: '4111111111111111',
+                month: '01',
+                year: '2022',
+                verification_value: '123',
+                name: 'John Doe'
+              }
+            }
+          }
+        
+
+

+ You can obtain payment_method_id by querying + GET /checkout/payment_methods endpoint. +

+

5. Complete checkout

+
PATCH /checkout/complete
+
+

+ This will complete the Checout and will marke the Order as completed. + You cannot execute any operations on this Order anymore via Storefront API. + Further operations on the Order are possible via Platform API. +

+ tags: + - Checkout + operationId: 'Update Checkout' + responses: + '200': + description: Checkout was updated + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Checkout couldn't be updated + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order: + type: object + properties: + email: + type: string + example: 'john@snow.org' + bill_address_attributes: + $ref: '#/components/schemas/AddressPayload' + ship_address_attributes: + $ref: '#/components/schemas/AddressPayload' + payments_attributes: + type: array + items: + type: object + properties: + payment_method_id: + type: number + example: 1 + description: 'ID of selected payment method' + shipments_attributes: + type: object + payment_source: + type: object + + '/checkout/next': + patch: + description: Goes to the next Checkout step + tags: + - Checkout + operationId: 'Checkout Next' + responses: + '200': + description: Checkout transitioned to the next step + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Checkout couldn't transition to the next step + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + + '/checkout/advance': + patch: + description: Advances Checkout to the furthest Checkout step validation allows, until the Complete step + tags: + - Checkout + operationId: 'Advance Checkout' + responses: + '200': + description: Checkout was advanced to the furthest step + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Checkout couldn't transition to the next step + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + + '/checkout/complete': + patch: + description: Completes the Checkout + tags: + - Checkout + operationId: 'Complete Checkout' + responses: + '200': + description: Checkout was completed + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Checkout couldn't be completed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + + '/checkout/add_store_credit': + post: + description: Adds Store Credit payments if a user has any + tags: + - Checkout + operationId: 'Add Store Credit' + responses: + '200': + description: Store Credit payment created + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Store Credit couldn't be created + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - in: query + name: amount + description: >- + Amount of Store Credit to use. + As much as possible Store Credit will be applied if no amount is passed. + schema: + type: string + example: 100.0 + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + '/checkout/remove_store_credit': + post: + description: Remove Store Credit payments if any applied + tags: + - Checkout + operationId: 'Remove Store Credit' + responses: + '200': + description: Store Credit payment removed + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + '422': + description: Store Credit payments weren't removed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/CartIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + + '/checkout/payment_methods': + get: + description: >- + Returns a list of available Payment Methods + tags: + - Checkout + operationId: 'Payment Methods' + responses: + '200': + description: Returns a list of available Payment Methods + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentMethodsList' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + + '/checkout/shipping_rates': + get: + description: >- + Returns a list of available Shipping Rates for Checkout. + Shipping Rates are grouped against Shipments. + Each checkout cna have multiple Shipments eg. some products are available + in stock and will be send out instantly and some needs to be backordered. + tags: + - Checkout + operationId: 'Shipping Rates' + responses: + '200': + description: Returns a list of available Shipping Rates for Checkout + content: + application/json: + schema: + $ref: '#/components/schemas/ShippingRatesList' + '404': + $ref: '#/components/responses/404NotFound' + security: + - orderToken: [] + - bearerAuth: [] + + '/products': + get: + description: >- + Returns a list of Products + tags: + - Products + operationId: 'Products List' + parameters: + - $ref: '#/components/parameters/FilterByIds' + - in: query + name: filter[price] + schema: + type: string + example: 10,100 + description: Filter Prodcuts based on price (minimum, maximum range) + - in: query + name: filter[taxons] + schema: + type: string + example: 1,2,3,4,5,6,7,8,9,10,11 + description: Filter Prodcuts based on taxons (IDs of categories, brands, etc) + - in: query + name: filter[name] + schema: + type: string + example: rails + description: Find Prodcuts with matching name (supports wild-card, partial-word match search) + - in: query + name: 'filter[options][tshirt-color]' + schema: + type: string + example: Red + description: >- + Find Prodcuts with Variants that have the specified option (eg. color, size) + and value (eg. red, XS) + - in: query + name: sort + schema: + type: string + example: >- + -updated_at,price + description: >- + Sort products based on: + + Use - sign to set descenging sort, eg. -updated_at + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/ProductIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns a list of Products + content: + application/json: + schema: + $ref: '#/components/schemas/ProductsList' + '/products/{id}': + get: + description: >- + To view the details for a single product, make a request using that + product's permalink:
GET /api/v2/products/a-product +

You may also query by the product's id attribute:
GET + /api/v2/products/1

Note that the API will attempt a + permalink lookup before an ID lookup. + tags: + - Products + operationId: 'Show Product' + parameters: + - $ref: '#/components/parameters/IdOrPermalink' + - $ref: '#/components/parameters/ProductIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns the requested Product + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '404': + $ref: '#/components/responses/404NotFound' + + '/taxons': + get: + description: >- + Returns a list of Taxons. You can filter out taxons by... + tags: + - Taxons + operationId: 'Taxons List' + parameters: + - $ref: '#/components/parameters/FilterByIds' + - $ref: '#/components/parameters/FilterByName' + - in: query + name: filter[parent_id] + schema: + type: string + example: '1' + description: Fetch children nodes of specified Taxon + - in: query + name: filter[taxonomy_id] + schema: + type: string + example: '1' + description: Fetch Taxons in a specified Taxonomy + - in: query + name: filter[roots] + schema: + type: boolean + example: false + description: Fetch only root Taxons (Taxonomies) + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/TaxonIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns a list of Taxons + content: + application/json: + schema: + $ref: '#/components/schemas/TaxonsList' + + '/taxons/{id}': + get: + description: >- + To view the details for a single Taxon, make a request using that + Taxon's permalink:
GET /api/v2/taxons/t-shirts +

You may also query by the Taxons's ID attribute:
GET + /api/v2/taxons/1

Note that the API will attempt a + permalink lookup before an ID lookup. + tags: + - Taxons + operationId: 'Show Taxon' + parameters: + - $ref: '#/components/parameters/IdOrPermalink' + - $ref: '#/components/parameters/TaxonIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns the reqested Taxon + content: + application/json: + schema: + $ref: '#/components/schemas/Taxon' + '404': + $ref: '#/components/responses/404NotFound' + + '/countries': + get: + description: >- + Returns a list of all Countries + tags: + - Countries + operationId: 'Countries List' + responses: + '200': + description: Returns a list of all Countries + content: + application/json: + schema: + $ref: '#/components/schemas/CountriesList' + + '/countries/{iso}': + get: + description: >- + To view the details for a single Country, make a request using that + Country's iso code:
GET /api/v2/storefront/countries/gb +

You may also query by the Country's iso3 code:
GET + /api/v2/storefront/coutries/gbr

Note that the API will attempt a + iso lookup before an iso3 lookup. + tags: + - Countries + operationId: 'Show Country' + parameters: + - $ref: '#/components/parameters/IsoOrIso3' + - $ref: '#/components/parameters/CountryIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns the requested Country + content: + application/json: + schema: + $ref: '#/components/schemas/Country' + '404': + $ref: '#/components/responses/404NotFound' + + '/countries/default': + get: + description: >- + Returns the default Country for the application. + By default this will be the US. + tags: + - Countries + operationId: 'Default Country' + parameters: + - $ref: '#/components/parameters/CountryIncludeParam' + - $ref: '#/components/parameters/SparseFieldsParam' + responses: + '200': + description: Returns the default Country + content: + application/json: + schema: + $ref: '#/components/schemas/Country' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: >- + User token to authorize Cart and Checkout requests. You can obtain it + from `http://your_store_url.com/spree_oauth` endpoint. + It is required to associate Cart with the User. + See OAuth documentation for more details. + orderToken: + type: apiKey + in: header + description: >- +

+ Order token to authorize Cart and Checkout requests. + Useful for guest checouts when you don't have the user token + (bearerAuth) +

+

+ You can obtain it from the `/cart` endpoint - + it's part of the response (value of the `token` field). +

+ name: X-Spree-Order-Token + schemas: + Error: + required: + - error + properties: + error: + type: string + ListLinks: + properties: + self: + type: string + description: 'URL to the current page of the listing' + next: + type: string + description: 'URL to the next page of the listing' + prev: + type: string + description: 'URL to the previous page of the listing' + last: + type: string + description: 'URL to the last page of the listing' + first: + type: string + description: 'URL to the first page of the listing' + ListMeta: + properties: + count: + type: number + example: 7 + description: 'Number of items on the current listing' + total_count: + type: number + example: 145 + description: 'Number of all items matching the criteria' + total_pages: + type: number + example: 10 + description: 'Number of all pages containing items matching the criteria' + Timestamp: + type: string + example: '2018-05-25T11:22:57.214-04:00' + Address: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'address' + attributes: + type: object + properties: + firstname: + type: string + example: 'John' + lastname: + type: string + example: 'Doe' + address1: + type: string + example: '1600 Amphitheatre Pkwy' + address2: + type: string + example: 'Suite 1' + city: + type: string + example: 'Mountain View' + zipcode: + type: string + example: '94043' + phone: + type: string + example: '(+1) 123 456 789' + state_name: + type: string + example: 'California' + state_code: + type: string + example: 'CA' + country_name: + type: string + example: 'United States of America' + country_iso3: + type: string + example: 'USA' + company: + type: string + example: 'Google Inc.' + Cart: + required: + - data + - included + properties: + data: + type: object + $ref: '#/components/schemas/CartAttributesWithRelationships' + required: + - id + - type + - attributes + - relationships + included: + type: array + items: + type: object + oneOf: + - $ref: '#/components/schemas/VariantAttributesAndRelationships' + - $ref: '#/components/schemas/LineItem' + - $ref: '#/components/schemas/Promotion' + - $ref: '#/components/schemas/User' + - $ref: '#/components/schemas/Address' + - $ref: '#/components/schemas/ShipmentAttributesWithoutRelationsips' + CartList: + required: + - data + - included + properties: + data: + required: + - id + - type + - attributes + - relationships + type: array + items: + $ref: '#/components/schemas/CartAttributesWithRelationships' + included: + type: array + items: + type: object + oneOf: + - $ref: '#/components/schemas/VariantAttributesAndRelationships' + - $ref: '#/components/schemas/LineItem' + - $ref: '#/components/schemas/Promotion' + - $ref: '#/components/schemas/User' + - $ref: '#/components/schemas/Address' + - $ref: '#/components/schemas/ShipmentAttributesWithoutRelationsips' + CartAttributesWithRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + example: 'cart' + attributes: + $ref: '#/components/schemas/CartAttributes' + relationships: + $ref: '#/components/schemas/CartRelationships' + CartRelationships: + type: object + properties: + line_items: + type: object + description: '' + properties: + data: + $ref: '#/components/schemas/Relation' + promotions: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + variants: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + user: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + billing_address: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + shipping_address: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + payments: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + shipments: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + CartAttributes: + type: object + properties: + number: + type: string + example: 'R123456789' + email: + type: string + example: 'spree@example.com' + item_total: + type: string + example: '19.99' + display_item_total: + type: string + example: '$19.99' + total: + type: string + example: '29.99' + display_total: + type: string + example: '$29.99' + ship_total: + type: string + example: '0.0' + display_ship_total: + type: string + example: '$19.99' + adjustment_total: + type: string + example: '10.0' + display_adjustment_total: + type: string + example: '$10.00' + promo_total: + type: string + example: '-10.0' + display_promo_total: + type: string + example: '-$10.00' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + included_tax_total: + type: string + example: '5.00' + additional_tax_total: + type: string + example: '5.0' + display_additional_tax_total: + type: string + example: '$5.00' + display_included_tax_total: + type: string + example: '$5.00' + tax_total: + type: string + example: '10.0' + display_tax_total: + type: string + example: '$10.00' + item_count: + type: number + example: 2 + description: 'Total quantity number of all items added to the Cart' + special_instructions: + type: string + example: 'Please wrap it as a gift' + description: 'Message added by the Customer' + currency: + type: string + example: 'USD' + state: + type: string + example: 'address' + description: 'State of the Cart in the Checkout flow' + token: + type: string + example: abcdef123456 + description: >- + Used for authorizing any action for an order within Spree’s + API + CreditCardList: + required: + - included + properties: + data: + type: array + items: + $ref: '#/components/schemas/CreditCardAttributesWithRelationships' + included: + type: array + items: + type: object + oneOf: + - $ref: '#/components/schemas/PaymentMethod' + CreditCard: + required: + - data + - included + properties: + data: + type: object + $ref: '#/components/schemas/CreditCardAttributesWithRelationships' + included: + type: array + items: + type: object + oneOf: + - $ref: '#/components/schemas/PaymentMethod' + CreditCardAttributes: + properties: + cc_type: + type: string + example: 'visa' + last_digits: + type: string + example: '1232' + description: 'Last 4 digits of CC number' + month: + type: integer + example: 10 + description: 'Expiration date month' + year: + type: integer + example: 2019 + description: 'Expiration date year' + name: + type: string + example: 'John Doe' + description: 'Card holder name' + default: + type: boolean + example: true + description: 'Defines if this is the default CC for a signed in user' + CreditCardAttributesWithRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + example: 'credit_card' + attributes: + type: object + $ref: '#/components/schemas/CreditCardAttributes' + relationships: + type: object + properties: + payment_method: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + Image: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'image' + attributes: + type: object + properties: + position: + type: integer + example: 0 + description: 'Sort order of images set in the Admin Panel' + styles: + type: array + description: 'An array of pre-scaled image styles' + items: + $ref: '#/components/schemas/ImageStyle' + ImageStyle: + properties: + url: + type: string + example: 'http://localhost:3000/rails/active_storage/disk/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJbWQyWVhKcFlXNTBjeTltWm1sMmRURlNORFpZWjJaSFpYUkdZMjk2WWsxM1RHWXZNVGs1T1RCak5XVmlNamN4TlRnd1pqVTBabUpqTWpCbFkyVXhZMlZpTTJFd05ERTJZemMzT0dKaE5tSTFNREkyT0dKaFpqa3paV1JtWTJWaE16aGxaQVk2QmtWVSIsImV4cCI6IjIwMTgtMDYtMjRUMTM6NTk6NTguOTY5WiIsInB1ciI6ImJsb2Jfa2V5In19--5e9ff358dc747f73754e332678c5762114ac6f3f/ror_jr_spaghetti.jpeg?content_type=image%2Fjpeg&disposition=inline%3B+filename%3D%22ror_jr_spaghetti.jpeg%22%3B+filename%2A%3DUTF-8%27%27ror_jr_spaghetti.jpeg' + description: 'Absolute URL of the uploaded image in selected style (width/height)' + width: + type: integer + example: 1920 + description: 'Actual width of image' + height: + type: integer + example: 1080 + description: 'Actual height of image' + User: + properties: + id: + type: string + example: 1 + type: + type: string + default: 'user' + attributes: + type: object + properties: + email: + type: string + example: 'spree@example.com' + LineItem: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'line_item' + attributes: + type: object + properties: + name: + type: string + example: 'Sample product' + quantity: + type: integer + example: 1 + slug: + type: string + example: 'sample-product' + options_text: + type: string + example: 'Size: small, Color: red' + price: + type: string + example: '125.0' + description: Price of Product per quantity + currency: + type: string + example: 'USD' + display_price: + type: string + example: '$125.00' + description: Price of Product per quantity + total: + type: string + example: '250.0' + description: >- + Total price of Line Item including adjastments, promotions + and taxes + display_total: + type: string + example: '$250.00' + description: >- + Total price of Line Item including adjastments, promotions + and taxes + adjustment_total: + type: string + example: '10.0' + description: TBD + display_adjustment_total: + type: string + example: '$10.00' + description: TBD + additional_tax_total: + type: string + example: '5.0' + display_additional_tax_total: + type: string + example: '$5.00' + discounted_amount: + type: string + example: '125.0' + display_discounted_amount: + type: string + example: '$125.00' + promo_total: + type: string + example: '-5.0' + display_promo_total: + type: string + included_tax_total: + type: string + example: '0.0' + description: 'Taxes included in the price, eg. VAT' + display_inluded_tax_total: + type: string + example: '$0.00' + relationships: + properties: + variant: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + Promotion: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'promotion' + attributes: + type: object + properties: + name: + type: string + example: '10% Discount' + descriptiom: + type: string + example: 'Super discount for you' + amount: + type: string + example: '-10.0' + display_amount: + type: string + example: '-$10.00' + Product: + required: + - data + - included + properties: + data: + $ref: '#/components/schemas/ProductAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/OptionType' + - $ref: '#/components/schemas/OptionValue' + - $ref: '#/components/schemas/ProductProperty' + - $ref: '#/components/schemas/Property' + - $ref: '#/components/schemas/VariantAttributesAndRelationships' + - $ref: '#/components/schemas/Image' + - $ref: '#/components/schemas/TaxonAttributesAndRelationships' + ProductsList: + required: + - links + - data + - included + properties: + links: + $ref: '#/components/schemas/ListLinks' + meta: + $ref: '#/components/schemas/ListMeta' + data: + type: array + items: + $ref: '#/components/schemas/ProductAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/OptionType' + - $ref: '#/components/schemas/OptionValue' + - $ref: '#/components/schemas/ProductProperty' + - $ref: '#/components/schemas/Property' + - $ref: '#/components/schemas/VariantAttributesAndRelationships' + - $ref: '#/components/schemas/Image' + - $ref: '#/components/schemas/TaxonAttributesAndRelationships' + ProductAttributes: + type: object + properties: + name: + type: string + example: 'Example product' + description: + type: string + example: 'Example description' + price: + type: string + example: '15.99' + currency: + type: string + example: 'USD' + display_price: + type: string + example: $15.99 + available_on: + type: string + example: '2012-10-17T03:43:57Z' + purchasable: + type: boolean + example: true + description: 'Indicates if any of Variants are in stock or backorderable' + in_stock: + type: boolean + example: true + description: 'Indicates if any of Variants are in stock' + backorderable: + type: boolean + example: true + description: 'Indicates if any of Variants are backeorderable' + slug: + type: string + example: 'example-product' + meta_description: + type: string + example: 'Example product' + meta_keywords: + type: string + example: 'example, product' + updated_at: + $ref: '#/components/schemas/Timestamp' + ProductRelationships: + type: object + properties: + default_variant: + type: object + description: 'The default Variant for given product' + properties: + data: + $ref: '#/components/schemas/Relation' + product_properties: + type: object + description: 'List of Product Properties' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + option_types: + type: object + description: 'List of Product Option Types' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + variants: + type: object + description: 'List of Product Variants, excluding Master Variant' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + taxons: + type: object + description: 'List of Taxons associated with Product' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + ProductAttributesAndRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'product' + attributes: + $ref: '#/components/schemas/ProductAttributes' + relationships: + $ref: '#/components/schemas/ProductRelationships' + Relation: + required: + - id + - type + properties: + id: + type: string + type: + type: string + Taxon: + required: + - data + - included + properties: + data: + $ref: '#/components/schemas/TaxonAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/ProductAttributesAndRelationships' + - $ref: '#/components/schemas/TaxonAttributesAndRelationships' + - $ref: '#/components/schemas/TaxonomyAttributesAndRelationships' + - $ref: '#/components/schemas/Image' + TaxonAttributes: + type: object + properties: + name: + type: string + example: 'T-shirts' + pretty_name: + type: string + example: 'Clothes > T-shirts' + permalink: + type: string + example: 't-shirts' + seo_title: + type: string + example: 'Clothes - T-shirts' + meta_title: + type: string + example: 'T-shirts' + meta_description: + type: string + example: 'A list of cool t-shirts ' + meta_keywords: + type: string + example: 't-shirts, cool' + left: + type: integer + example: 1 + right: + type: integer + example: 2 + position: + type: integer + example: 0 + depth: + type: integer + example: 1 + is_root: + type: boolean + example: true + description: 'Indicates if the Taxon is the root node of this Taxonomy tree' + is_child: + type: boolean + example: true + description: 'Returns true is this is a child node of this Taxonomy tree' + is_leaf: + type: boolean + example: false + description: 'Returns true if this is the end of a branch of this Taxonomy tree' + updated_at: + type: string + example: '2018-06-18T10:57:29.704Z' + TaxonRelationships: + type: object + properties: + parent: + type: object + description: 'Parent node' + properties: + data: + $ref: '#/components/schemas/Relation' + children: + type: object + description: 'List of child nodes' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + taxonomy: + type: object + description: 'Taxonomy associated with this Taxon' + properties: + data: + $ref: '#/components/schemas/Relation' + image: + type: object + description: 'Image associated with Taxon' + properties: + data: + $ref: '#/components/schemas/Relation' + products: + type: object + description: 'List of active and available Products associated with this Taxon' + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + TaxonAttributesAndRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'taxon' + attributes: + $ref: '#/components/schemas/TaxonAttributes' + relationships: + $ref: '#/components/schemas/TaxonRelationships' + TaxonsList: + required: + - links + - data + - included + properties: + links: + $ref: '#/components/schemas/ListLinks' + meta: + $ref: '#/components/schemas/ListMeta' + data: + type: array + items: + $ref: '#/components/schemas/TaxonAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/TaxonAttributesAndRelationships' + - $ref: '#/components/schemas/TaxonImage' + - $ref: '#/components/schemas/TaxonomyAttributesAndRelationships' + - $ref: '#/components/schemas/ProductAttributesAndRelationships' + TaxonImage: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'taxon_image' + attributes: + type: object + properties: + position: + type: integer + example: 0 + description: 'Sort order of images set in the Admin Panel' + styles: + type: array + description: 'An array of pre-scaled image styles' + items: + $ref: '#/components/schemas/ImageStyle' + TaxonomyAttributesAndRelationships: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'taxonomy' + attributes: + $ref: '#/components/schemas/TaxonomyAttributes' + TaxonomyAttributes: + type: object + properties: + name: + type: string + example: 'Categories' + position: + type: integer + example: 0 + OptionType: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'option_type' + attributes: + type: object + properties: + name: + type: string + example: 'color' + presentation: + type: string + example: 'Color' + position: + type: integer + example: 1 + OptionValue: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'option_value' + attributes: + type: object + properties: + name: + type: string + example: 'red' + presentation: + type: string + example: 'Red' + position: + type: integer + example: 1 + relationships: + type: object + properties: + option_type: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + Property: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'property' + attributes: + type: object + properties: + name: + type: string + example: 'material' + presentation: + type: string + example: 'Material' + ProductProperty: + required: + - data + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + default: 'product_property' + attributes: + type: object + properties: + name: + type: string + example: 'silk' + presentation: + type: string + example: 'Silk' + relationships: + type: object + properties: + property: + type: object + properties: + data: + $ref: '#/components/schemas/Property' + Variant: + required: + - data + - included + properties: + data: + $ref: '#/components/schemas/VariantAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Image' + - $ref: '#/components/schemas/OptionValue' + - $ref: '#/components/schemas/OptionType' + VariantAttributes: + type: object + properties: + name: + type: string + example: 'Example product' + description: 'Product name' + sku: + type: string + example: 'SKU-1001' + price: + type: string + example: '15.99' + currency: + type: string + example: 'USD' + display_price: + type: string + example: '$15.99' + weight: + type: string + example: '10' + height: + type: string + example: '10' + width: + type: string + example: '10' + depth: + type: string + example: '10' + is_master: + type: boolean + example: false + description: 'Indicates if Variant is the master Variant' + options_text: + type: string + example: 'Size: small, Color: red' + slug: + type: string + example: 'example-product' + description: 'Product slug' + description: + type: string + example: 'Example description' + description: 'Product description' + purchasable: + type: boolean + example: true + description: 'Indicates if Variant is in stock or backorderable' + in_stock: + type: boolean + example: true + description: 'Indicates if Variant is in stock' + backorderable: + type: boolean + example: true + description: 'Indicates if Variant is backorderable' + VariantRelationships: + type: object + properties: + product: + type: object + properties: + data: + $ref: '#/components/schemas/Relation' + images: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + option_values: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Relation' + VariantAttributesAndRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'variant' + attributes: + $ref: '#/components/schemas/VariantAttributes' + relationships: + $ref: '#/components/schemas/VariantRelationships' + Country: + required: + - data + - included + properties: + data: + $ref: '#/components/schemas/CountryAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/State' + CountriesList: + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/CountryAttributesAndRelationships' + CountryAttributes: + type: object + properties: + iso: + type: string + example: 'us' + iso3: + type: string + example: 'usa' + iso_name: + type: string + example: 'UNITED STATES' + name: + type: string + example: 'United States' + states_required: + type: boolean + example: true + zipcode_required: + type: boolean + example: true + default: + type: boolean + example: true + CountryRelationships: + type: object + properties: + states: + type: object + description: 'States associated with this Country' + properties: + data: + $ref: '#/components/schemas/Relation' + CountryAttributesAndRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'country' + attributes: + $ref: '#/components/schemas/CountryAttributes' + relationships: + $ref: '#/components/schemas/CountryRelationships' + State: + type: object + properties: + abbr: + type: string + example: 'NY' + name: + type: string + example: 'New York' + PaymentMethodsList: + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/PaymentMethod' + PaymentMethod: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'payment_method' + attributes: + type: object + properties: + type: + type: string + example: 'Spree::Gateway::StripeGateway' + name: + type: string + example: 'Stripe' + description: + type: string + example: 'Stripe Payments' + ShipmentAttributesWithoutRelationsips: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'shipment' + attributes: + $ref: '#/components/schemas/ShipmentAttributes' + relationships: + type: object + ShipmentAttributesAndRelationsips: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'shipment' + attributes: + $ref: '#/components/schemas/ShipmentAttributes' + relationships: + properties: + shipping_rates: + properties: + data: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Relation' + ShipmentAttributes: + properties: + number: + type: string + example: 'H121354' + description: 'Unique Shipment identifier' + free: + type: boolean + example: true + description: 'Indicates if the Shipping Rate is free, eg. when Free shipping promo applied to Cart' + final_price: + type: string + example: '10.0' + description: 'Price to be presented for the Customer' + display_final_price: + type: string + example: '$10.00' + tracking_url: + type: string + example: 'https://tools.usps.com/go/TrackConfirmAction?tRef=fullpage&tLc=2&text28777=&tLabels=4123412434%2C' + description: 'Tracking URL to the service provider website' + state: + type: string + example: 'shipped' + description: >- + Status of the Shipment. For list of all available statuses please refer: + + Shipment section in Spree Guides + + shipped_at: + type: string + format: 'date-time' + example: '2019-01-02 13:42:12 UTC' + description: 'Date when Shipment was being sent from the warehouse' + ShippingRatesList: + required: + - data + - included + properties: + data: + type: array + items: + $ref: '#/components/schemas/ShipmentAttributesAndRelationsips' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/ShippingRate' + ShippingRate: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'shipping_rate' + attributes: + type: object + properties: + name: + type: string + example: 'USPS Ground' + selected: + type: boolean + example: true + free: + type: boolean + example: true + description: 'Indicates if the Shipping Rate is free, eg. when Free shipping promo applied to Cart' + final_price: + type: string + example: '10.0' + description: 'Price to be presented for the Customer' + display_final_price: + type: string + example: '$10.00' + cost: + type: string + example: '10.0' + description: 'Price of the service without discounts applied' + display_cost: + type: string + example: '$10.00' + tax_amount: + type: string + example: '0.0' + description: 'Eligible tax for service (if any)' + display_tax_amount: + type: string + example: '$0.00' + shipping_method_id: + type: integer + example: 1 + description: 'ID of a Shipping Method. You will need this for the Checkout Update action' + Account: + required: + - data + - included + properties: + data: + $ref: '#/components/schemas/AccountAttributesAndRelationships' + included: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Address' + AccountAttributes: + type: object + properties: + email: + type: string + example: 'spree@example.com' + store_credits: + type: number + example: 150.75 + completed_orders: + type: number + example: 3 + AccountRelationships: + type: object + properties: + default_billing_address: + type: object + description: 'Default billing address associated with this Account' + properties: + data: + $ref: '#/components/schemas/Relation' + default_shipping_address: + type: object + description: 'Default shipping address associated with this Account' + properties: + data: + $ref: '#/components/schemas/Relation' + AccountAttributesAndRelationships: + properties: + id: + type: string + example: '1' + type: + type: string + default: 'user' + attributes: + $ref: '#/components/schemas/AccountAttributes' + relationships: + $ref: '#/components/schemas/AccountRelationships' + AddressPayload: + properties: + firstname: + type: string + lastname: + type: string + address1: + type: string + description: 'Street address' + address2: + type: string + description: 'Additional address information, floor no etc' + city: + type: string + description: 'City, town' + phone: + type: string + zipcode: + type: string + description: 'Valid zipcode, will be validated against the selected Country' + state_name: + type: string + description: 'State/region/province 2 letter abbrevation' + country_iso: + type: string + description: >- + Country ISO (2-chars) or ISO3 (3-chars) code, list of codes: + + https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes + + example: + firstname: 'John' + lastname: 'Snow' + address1: '7735 Old Georgetown Road' + address2: '2nd Floor' + city: 'Bethesda' + phone: '3014445002' + zipcode: '20814' + state_name: 'MD' + country_iso: 'US' + + parameters: + CreditCardIncludeParam: + name: include + in: query + required: false + schema: + type: string + example: 'payment_method' + description: >- +

Specify what related resources (relationships) you would like to receive in the response body.

+

You can also fetch relationships of relationships.

+

+ Format: +

+

+ More information: + + https://jsonapi.org/format/#fetching-includes + +

+ IdOrPermalink: + name: id + in: path + required: true + description: ID or a permalink + schema: + type: string + examples: + ID: + value: '1' + Permalink: + value: 'some-product' + LineItemId: + name: line_item_id + in: path + required: true + description: Line Item ID + schema: + type: string + example: '1' + PageParam: + name: page + in: query + description: Number of requested page when paginating collection + schema: + type: integer + example: 1 + PerPageParam: + name: per_page + in: query + description: Number of requested records per page when paginating collection + schema: + type: integer + example: + OrderParam: + name: number + in: path + required: true + description: Number of Order + schema: + type: string + example: 'R653163382' + CartIncludeParam: + name: include + in: query + schema: + type: string + description: >- +

Specify what related resources (relationships) you would like to receive in the response body.

+

You can also fetch relationships of relationships.

+

+ Format: +

+

+ More information: + + https://jsonapi.org/format/#fetching-includes + +

+ example: 'line_items,variants,variants.images,billing_address,shipping_address,user,payments,shipments,promotions' + ProductIncludeParam: + name: include + in: query + schema: + type: string + description: >- +

Specify what related resources (relationships) you would like to receive in the response body.

+

You can also fetch relationships of relationships.

+

+ Format: +

+

+ More information: + + https://jsonapi.org/format/#fetching-includes + +

+ example: 'default_variant,variants,option_types,product_properties,taxons,images' + TaxonIncludeParam: + name: include + in: query + schema: + type: string + description: >- +

Specify what related resources (relationships) you would like to receive in the response body.

+

You can also fetch relationships of relationships.

+

+ Format: +

+

+ More information: + + https://jsonapi.org/format/#fetching-includes + +

+ example: 'parent,taxonomy,children,image,products' + IsoOrIso3: + name: iso + in: path + required: true + description: ISO or ISO3 + schema: + type: string + CountryIncludeParam: + name: include + in: query + schema: + type: string + description: >- + Pass `states` as value to include States / Regions for each Country + example: 'states' + AccountIncludeParam: + name: include + in: query + schema: + type: string + description: >- + Pass `default_billing_address` and/or `default_shipping_address` as value to include selected addresses information + example: 'default_billing_address,default_shipping_address' + FilterByIds: + in: query + name: filter[ids] + schema: + type: string + example: 1,2,3 + description: Fetch only resources with corresponding IDs + FilterByName: + in: query + name: filter[name] + schema: + type: string + example: rails + description: Find resources with matching name (supports wild-card, partial-word match search) + SparseFieldsParam: + in: query + name: fields + style: deepObject + description: >- +

Specify what attributes for given types you would like to receive in the response body.

+

+ Format: +

+

+ More information: + + https://jsonapi.org/format/#fetching-sparse-fieldsets + +

+ example: { "cart": "total,currency,number" } + schema: + type: object + responses: + 404NotFound: + description: Resource not found + content: + application/json: + schema: + properties: + error: + type: string + example: 'The resource you were looking for could not be found.' + default: 'The resource you were looking for could not be found.' + 403Forbidden: + description: You are not authorized to access this page. + content: + application/json: + schema: + properties: + error: + type: string + example: 'You are not authorized to access this page.' + default: 'You are not authorized to access this page.' diff --git a/api/lib/spree/api.rb b/api/lib/spree/api.rb new file mode 100644 index 00000000000..f20221cf112 --- /dev/null +++ b/api/lib/spree/api.rb @@ -0,0 +1,10 @@ +require 'spree/core' + +require 'rabl' + +module Spree + module Api + end +end + +require 'spree/api/engine' diff --git a/api/lib/spree/api/controller_setup.rb b/api/lib/spree/api/controller_setup.rb new file mode 100644 index 00000000000..7963b0e0314 --- /dev/null +++ b/api/lib/spree/api/controller_setup.rb @@ -0,0 +1,20 @@ +require 'spree/api/responders' + +module Spree + module Api + module ControllerSetup + def self.included(klass) + klass.class_eval do + include CanCan::ControllerAdditions + include Spree::Core::ControllerHelpers::Auth + + prepend_view_path Rails.root + 'app/views' + append_view_path File.expand_path('../../../app/views', File.dirname(__FILE__)) + + self.responder = Spree::Api::Responders::AppResponder + respond_to :json + end + end + end + end +end diff --git a/api/lib/spree/api/engine.rb b/api/lib/spree/api/engine.rb new file mode 100644 index 00000000000..64b378376b6 --- /dev/null +++ b/api/lib/spree/api/engine.rb @@ -0,0 +1,59 @@ +require 'rails/engine' +require 'versioncake' + +module Spree + module Api + class Engine < Rails::Engine + isolate_namespace Spree + engine_name 'spree_api' + + Rabl.configure do |config| + config.include_json_root = false + config.include_child_root = false + + # Motivation here it make it call as_json when rendering timestamps + # and therefore display miliseconds. Otherwise it would fall to + # JSON.dump which doesn't display the miliseconds + config.json_engine = ActiveSupport::JSON + end + + initializer 'spree.api.versioncake' do |_app| + VersionCake.setup do |config| + config.resources do |r| + r.resource %r{.*}, [], [], [1, 2] + end + + config.missing_version = 1 + config.extraction_strategy = :http_header + end + end + + # sets the manifests / assets to be precompiled, even when initialize_on_precompile is false + initializer 'spree.assets.precompile', group: :all do |app| + app.config.assets.precompile += %w[ + spree/api/all* + ] + end + + initializer 'spree.api.environment', before: :load_config_initializers do |_app| + Spree::Api::Config = Spree::ApiConfiguration.new + Spree::Api::Dependencies = Spree::ApiDependencies.new + end + + initializer 'spree.api.checking_migrations' do + Migrations.new(config, engine_name).check + end + + def self.activate + Dir.glob(File.join(File.dirname(__FILE__), '../../../app/**/*_decorator*.rb')) do |c| + Rails.configuration.cache_classes ? require(c) : load(c) + end + end + config.to_prepare &method(:activate).to_proc + + def self.root + @root ||= Pathname.new(File.expand_path('../../..', __dir__)) + end + end + end +end diff --git a/api/lib/spree/api/responders.rb b/api/lib/spree/api/responders.rb new file mode 100644 index 00000000000..deb18094d16 --- /dev/null +++ b/api/lib/spree/api/responders.rb @@ -0,0 +1,11 @@ +require 'spree/api/responders/rabl_template' + +module Spree + module Api + module Responders + class AppResponder < ActionController::Responder + include RablTemplate + end + end + end +end diff --git a/api/lib/spree/api/responders/rabl_template.rb b/api/lib/spree/api/responders/rabl_template.rb new file mode 100644 index 00000000000..dc853772e7e --- /dev/null +++ b/api/lib/spree/api/responders/rabl_template.rb @@ -0,0 +1,28 @@ +module Spree + module Api + module Responders + module RablTemplate + def to_format + if template + render template, status: options[:status] || 200 + else + super + end + rescue ActionView::MissingTemplate => e + api_behavior + end + + def template + options[:default_template] + end + + def api_behavior + if controller.params[:action] == 'destroy' + # Render a blank template + super + end + end + end + end + end +end diff --git a/api/lib/spree/api/testing_support/caching.rb b/api/lib/spree/api/testing_support/caching.rb new file mode 100644 index 00000000000..550a646f11b --- /dev/null +++ b/api/lib/spree/api/testing_support/caching.rb @@ -0,0 +1,10 @@ +RSpec.configure do |config| + config.before(:each, caching: true) do + ActionController::Base.perform_caching = true + end + + config.after(:each, caching: true) do + ActionController::Base.perform_caching = false + Rails.cache.clear + end +end diff --git a/api/lib/spree/api/testing_support/helpers.rb b/api/lib/spree/api/testing_support/helpers.rb new file mode 100644 index 00000000000..0085aa7cfc0 --- /dev/null +++ b/api/lib/spree/api/testing_support/helpers.rb @@ -0,0 +1,44 @@ +module Spree + module Api + module TestingSupport + module Helpers + def json_response + case body = JSON.parse(response.body) + when Hash + body.with_indifferent_access + when Array + body + end + end + + def assert_not_found! + expect(json_response).to eq('error' => 'The resource you were looking for could not be found.') + expect(response.status).to eq 404 + end + + def assert_unauthorized! + expect(json_response).to eq('error' => 'You are not authorized to perform that action.') + expect(response.status).to eq 401 + end + + def stub_authentication! + allow(Spree.user_class).to receive(:find_by).with(hash_including(:spree_api_key)) { current_api_user } + end + + # This method can be overriden (with a let block) inside a context + # For instance, if you wanted to have an admin user instead. + def current_api_user + @current_api_user ||= stub_model(Spree.user_class, email: 'spree@example.com') + end + + def image(filename) + File.open(Spree::Api::Engine.root + 'spec/fixtures' + filename) + end + + def upload_image(filename) + fixture_file_upload(image(filename).path, 'image/jpg') + end + end + end + end +end diff --git a/api/lib/spree/api/testing_support/setup.rb b/api/lib/spree/api/testing_support/setup.rb new file mode 100644 index 00000000000..2210cba271b --- /dev/null +++ b/api/lib/spree/api/testing_support/setup.rb @@ -0,0 +1,16 @@ +module Spree + module Api + module TestingSupport + module Setup + def sign_in_as_admin! + let!(:current_api_user) do + user = stub_model(Spree.user_class) + allow(user).to receive_message_chain(:spree_roles, :pluck).and_return(['admin']) + allow(user).to receive(:has_spree_role?).with('admin').and_return(true) + user + end + end + end + end + end +end diff --git a/api/lib/spree_api.rb b/api/lib/spree_api.rb new file mode 100644 index 00000000000..6ebec393571 --- /dev/null +++ b/api/lib/spree_api.rb @@ -0,0 +1,5 @@ +require 'spree/api' +require 'spree/api/responders' +require 'versioncake' +require 'fast_jsonapi' +require 'doorkeeper' diff --git a/api/script/rails b/api/script/rails new file mode 100755 index 00000000000..674d60e6c86 --- /dev/null +++ b/api/script/rails @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. + +ENGINE_ROOT = File.expand_path('../..', __FILE__) +ENGINE_PATH = File.expand_path('../../lib/spree/api/engine', __FILE__) + +require 'rails/all' +require 'rails/engine/commands' + diff --git a/api/spec/controllers/spree/api/base_controller_spec.rb b/api/spec/controllers/spree/api/base_controller_spec.rb new file mode 100644 index 00000000000..7cb29e85ecf --- /dev/null +++ b/api/spec/controllers/spree/api/base_controller_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +class FakesController < Spree::Api::BaseController +end + +describe Spree::Api::BaseController, type: :controller do + render_views + controller(Spree::Api::BaseController) do + def index + render plain: { 'products' => [] }.to_json + end + end + + before do + @routes = ActionDispatch::Routing::RouteSet.new.tap do |r| + r.draw { get 'index', to: 'spree/api/base#index' } + end + end + + context 'when validating based on an order token' do + let!(:order) { create :order } + + context 'with a correct order token' do + it 'succeeds' do + api_get :index, order_token: order.token, order_id: order.number + expect(response.status).to eq(200) + end + + it 'succeeds with an order_number parameter' do + api_get :index, order_token: order.token, order_number: order.number + expect(response.status).to eq(200) + end + end + + context 'with an incorrect order token' do + it 'returns unauthorized' do + api_get :index, order_token: 'NOT_A_TOKEN', order_id: order.number + expect(response.status).to eq(401) + end + end + end + + context 'cannot make a request to the API' do + it 'without an API key' do + api_get :index + expect(json_response).to eq('error' => 'You must specify an API key.') + expect(response.status).to eq(401) + end + + it 'with an invalid API key' do + request.headers['X-Spree-Token'] = 'fake_key' + get :index + expect(json_response).to eq('error' => 'Invalid API key (fake_key) specified.') + expect(response.status).to eq(401) + end + + it 'using an invalid token param' do + get :index, params: { token: 'fake_key' } + expect(json_response).to eq('error' => 'Invalid API key (fake_key) specified.') + end + end + + it 'handles parameter missing exceptions' do + expect(subject).to receive(:authenticate_user).and_return(true) + expect(subject).to receive(:load_user_roles).and_return(true) + expect(subject).to receive(:index).and_raise(ActionController::ParameterMissing.new('foo')) + get :index, params: { token: 'exception-message' } + expect(json_response).to eql('exception' => 'param is missing or the value is empty: foo') + end + + it 'handles record invalid exceptions' do + expect(subject).to receive(:authenticate_user).and_return(true) + expect(subject).to receive(:load_user_roles).and_return(true) + resource = Spree::Product.new + resource.valid? # get some errors + expect(subject).to receive(:index).and_raise(ActiveRecord::RecordInvalid.new(resource)) + get :index, params: { token: 'exception-message' } + expect(json_response).to eql('exception' => "Validation failed: Name can't be blank, Shipping Category can't be blank, Price can't be blank") + end + + it 'lets a subclass override the product associations that are eager-loaded' do + expect(controller.respond_to?(:product_includes, true)).to be true + end +end diff --git a/api/spec/controllers/spree/api/v1/addresses_controller_spec.rb b/api/spec/controllers/spree/api/v1/addresses_controller_spec.rb new file mode 100644 index 00000000000..a1e4718648c --- /dev/null +++ b/api/spec/controllers/spree/api/v1/addresses_controller_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +module Spree + describe Api::V1::AddressesController, type: :controller do + render_views + + before do + stub_authentication! + @address = create(:address) + @order = create(:order, bill_address: @address) + end + + context 'with their own address' do + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + end + + it 'gets an address' do + api_get :show, id: @address.id, order_id: @order.number + expect(json_response['address1']).to eq @address.address1 + end + + it 'updates an address' do + api_put :update, id: @address.id, order_id: @order.number, + address: { address1: '123 Test Lane' } + expect(json_response['address1']).to eq '123 Test Lane' + end + + it 'receives the errors object if address is invalid' do + api_put :update, id: @address.id, order_id: @order.number, + address: { address1: '' } + + expect(json_response['error']).not_to be_nil + expect(json_response['errors']).not_to be_nil + expect(json_response['errors']['address1'].first).to eq "can't be blank" + end + end + + context 'on an address that does not belong to this order' do + before do + @order.bill_address_id = nil + @order.ship_address = nil + end + + it 'cannot retrieve address information' do + api_get :show, id: @address.id, order_id: @order.number + assert_unauthorized! + end + + it 'cannot update address information' do + api_get :update, id: @address.id, order_id: @order.number + assert_unauthorized! + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/checkouts_controller_spec.rb b/api/spec/controllers/spree/api/v1/checkouts_controller_spec.rb new file mode 100644 index 00000000000..73df240f485 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/checkouts_controller_spec.rb @@ -0,0 +1,376 @@ +require 'spec_helper' + +module Spree + describe Api::V1::CheckoutsController, type: :controller do + render_views + + shared_examples_for 'action which loads order using load_order_with_lock' do + before do + allow(controller).to receive(:load_order).with(true).and_return(true) + end + + it 'invokes load_order_with_lock' do + expect(controller).to receive(:load_order_with_lock).once + end + + it 'invokes load_order' do + expect(controller).to receive(:load_order).with(true).once.and_return(true) + end + + context 'ensure no double_render_error' do + before do + def controller.load_order(*) + respond_with(@order, default_template: 'spree/api/v1/orders/show', status: 200) + end + end + + it 'does not generate double_render_error' do + expect(response).to be_successful + end + end + + after do + send_request + end + end + + before do + stub_authentication! + Spree::Config[:track_inventory_levels] = false + country_zone = create(:zone, name: 'CountryZone') + @state = create(:state) + @country = @state.country + country_zone.members.create(zoneable: @country) + create(:stock_location) + + @shipping_method = create(:shipping_method, zones: [country_zone]) + @payment_method = create(:credit_card_payment_method) + end + + after do + Spree::Config[:track_inventory_levels] = true + end + + context "PUT 'update'" do + let(:order) do + order = create(:order_with_line_items) + # Order should be in a pristine state + # Without doing this, the order may transition from 'cart' straight to 'delivery' + order.shipments.delete_all + order + end + + before do + allow_any_instance_of(Order).to receive_messages(confirmation_required?: true) + allow_any_instance_of(Order).to receive_messages(payment_required?: true) + end + + it 'transitions a recently created order from cart to address' do + expect(order.state).to eq 'cart' + expect(order.email).not_to be_nil + api_put :update, id: order.to_param, order_token: order.token + expect(order.reload.state).to eq 'address' + end + + it 'transitions a recently created order from cart to address with order token in header' do + expect(order.state).to eq 'cart' + expect(order.email).not_to be_nil + request.headers['X-Spree-Order-Token'] = order.token + api_put :update, id: order.to_param + expect(order.reload.state).to eq 'address' + end + + it 'can take line_items_attributes as a parameter' do + line_item = order.line_items.first + api_put :update, id: order.to_param, order_token: order.token, + order: { line_items_attributes: { 0 => { id: line_item.id, quantity: 1 } } } + expect(response.status).to eq(200) + expect(order.reload.state).to eq 'address' + end + + it 'can take line_items as a parameter' do + line_item = order.line_items.first + api_put :update, id: order.to_param, order_token: order.token, + order: { line_items: { 0 => { id: line_item.id, quantity: 1 } } } + expect(response.status).to eq(200) + expect(order.reload.state).to eq 'address' + end + + it 'will return an error if the order cannot transition' do + skip 'not sure if this test is valid' + order.bill_address = nil + order.save + order.update_column(:state, 'address') + api_put :update, id: order.to_param, order_token: order.token + # Order has not transitioned + expect(response.status).to eq(422) + end + + context 'transitioning to delivery' do + before do + order.update_column(:state, 'address') + end + + let(:address) do + { + firstname: 'John', + lastname: 'Doe', + address1: '7735 Old Georgetown Road', + city: 'Bethesda', + phone: '3014445002', + zipcode: '20814', + state_id: @state.id, + country_id: @country.id + } + end + + it 'can update addresses and transition from address to delivery' do + api_put :update, + id: order.to_param, order_token: order.token, + order: { + bill_address_attributes: address, + ship_address_attributes: address + } + expect(json_response['state']).to eq('delivery') + expect(json_response['bill_address']['firstname']).to eq('John') + expect(json_response['ship_address']['firstname']).to eq('John') + expect(response.status).to eq(200) + end + + # Regression Spec for #5389 & #5880 + it 'can update addresses but not transition to delivery w/o shipping setup' do + Spree::ShippingMethod.destroy_all + api_put :update, + id: order.to_param, order_token: order.token, + order: { + bill_address_attributes: address, + ship_address_attributes: address + } + expect(json_response['error']).to eq(I18n.t(:could_not_transition, scope: 'spree.api.order')) + expect(response.status).to eq(422) + end + + # Regression test for #4498 + it 'does not contain duplicate variant data in delivery return' do + api_put :update, + id: order.to_param, order_token: order.token, + order: { + bill_address_attributes: address, + ship_address_attributes: address + } + # Shipments manifests should not return the ENTIRE variant + # This information is already present within the order's line items + expect(json_response['shipments'].first['manifest'].first['variant']).to be_nil + expect(json_response['shipments'].first['manifest'].first['variant_id']).not_to be_nil + end + end + + it 'can update shipping method and transition from delivery to payment' do + order.update_column(:state, 'delivery') + shipment = create(:shipment, order: order) + shipment.refresh_rates + shipping_rate = shipment.shipping_rates.where(selected: false).first + api_put :update, id: order.to_param, order_token: order.token, + order: { shipments_attributes: { '0' => { selected_shipping_rate_id: shipping_rate.id, id: shipment.id } } } + expect(response.status).to eq(200) + # Find the correct shipment... + json_shipment = json_response['shipments'].detect { |s| s['id'] == shipment.id } + # Find the correct shipping rate for that shipment... + json_shipping_rate = json_shipment['shipping_rates'].detect { |sr| sr['id'] == shipping_rate.id } + # ... And finally ensure that it's selected + expect(json_shipping_rate['selected']).to be true + # Order should automatically transfer to payment because all criteria are met + expect(json_response['state']).to eq('payment') + end + + it 'can update payment method and transition from payment to confirm' do + allow_any_instance_of(Spree::PaymentMethod).to receive(:source_required?).and_return(false) + order.update_column(:state, 'payment') + api_put :update, id: order.to_param, order_token: order.token, + order: { payments_attributes: [{ payment_method_id: @payment_method.id }] } + expect(json_response['state']).to eq('confirm') + expect(json_response['payments'][0]['payment_method']['name']).to eq(@payment_method.name) + expect(json_response['payments'][0]['amount']).to eq(order.total.to_s) + expect(response.status).to eq(200) + end + + it 'can update payment method with source and transition from payment to confirm' do + order.update_column(:state, 'payment') + source_attributes = { + number: '4111111111111111', + month: 1.month.from_now.month, + year: 1.month.from_now.year, + verification_value: '123', + name: 'Spree Commerce' + } + + api_put :update, id: order.to_param, order_token: order.token, + order: { payments_attributes: [{ payment_method_id: @payment_method.id.to_s, source_attributes: source_attributes }] } + expect(json_response['payments'][0]['payment_method']['name']).to eq(@payment_method.name) + expect(json_response['payments'][0]['amount']).to eq(order.total.to_s) + expect(response.status).to eq(200) + end + + it 'returns errors when source is missing attributes' do + order.update_column(:state, 'payment') + api_put :update, id: order.to_param, order_token: order.token, + order: { + payments_attributes: [{ payment_method_id: @payment_method.id }] + }, + payment_source: { + @payment_method.id.to_s => { name: 'Spree' } + } + + expect(response.status).to eq(422) + cc_errors = json_response['errors']['payments.Credit Card'] + expect(cc_errors).to include("Number can't be blank") + expect(cc_errors).to include('Month is not a number') + expect(cc_errors).to include('Year is not a number') + expect(cc_errors).to include("Verification Value can't be blank") + end + + it 'allow users to reuse a credit card' do + order.update_column(:state, 'payment') + credit_card = create(:credit_card, user_id: order.user_id, payment_method_id: @payment_method.id) + + api_put :update, id: order.to_param, order_token: order.token, + order: { existing_card: credit_card.id } + + expect(response.status).to eq 200 + expect(order.credit_cards).to match_array [credit_card] + end + + it 'can transition from confirm to complete' do + order.update_columns(state: 'confirm') + allow_any_instance_of(Spree::Order).to receive_messages(payment_required?: false) + api_put :update, id: order.to_param, order_token: order.token + expect(json_response['state']).to eq('complete') + expect(response.status).to eq(200) + end + + it 'can transition from confirm to delivery wtih logging state changes' do + order.update_columns(state: 'confirm') + allow_any_instance_of(Spree::Order).to receive_messages(payment_required?: false) + api_put :update, state: 'delivery', id: order.to_param, order_token: order.token + expect(response.status).to eq(200) + expect(order.state_changes.count).to eq 3 + end + + it 'prevent normal user from updating completed order' do + order.update_columns(completed_at: Time.current, state: 'complete') + api_put :update, id: order.to_param, order_token: order.token + assert_unauthorized! + end + + # Regression test for #3784 + it 'can update the special instructions for an order' do + instructions = "Don't drop it. (Please)" + api_put :update, id: order.to_param, order_token: order.token, + order: { special_instructions: instructions } + expect(json_response['special_instructions']).to eql(instructions) + end + + context 'as an admin' do + sign_in_as_admin! + it 'can assign a user to the order' do + user = create(:user) + # Need to pass email as well so that validations succeed + api_put :update, id: order.to_param, order_token: order.token, + order: { user_id: user.id, email: 'guest@spreecommerce.org' } + expect(response.status).to eq(200) + expect(json_response['user_id']).to eq(user.id) + end + end + + it 'can assign an email to the order' do + api_put :update, id: order.to_param, order_token: order.token, + order: { email: 'guest@spreecommerce.org' } + expect(json_response['email']).to eq('guest@spreecommerce.org') + expect(response.status).to eq(200) + end + + it 'can apply a coupon code to an order' do + order.update_column(:state, 'payment') + expect(PromotionHandler::Coupon).to receive(:new).with(order).and_call_original + expect_any_instance_of(PromotionHandler::Coupon).to receive(:apply).and_return(coupon_applied?: true) + api_put :update, id: order.to_param, order_token: order.token, order: { coupon_code: 'foobar' } + end + + def send_request + api_put :update, id: order.to_param, order_token: order.token + end + + it_behaves_like 'action which loads order using load_order_with_lock' + end + + context "PUT 'next'" do + let!(:order) { create(:order_with_line_items) } + + it 'cannot transition to address without a line item' do + order.line_items.delete_all + order.update_column(:email, 'spree@example.com') + api_put :next, id: order.to_param, order_token: order.token + expect(response.status).to eq(422) + expect(json_response['errors']['base']).to include(Spree.t(:there_are_no_items_for_this_order)) + end + + it 'can transition an order to the next state' do + order.update_column(:email, 'spree@example.com') + + api_put :next, id: order.to_param, order_token: order.token + expect(response.status).to eq(200) + expect(json_response['state']).to eq('address') + end + + it 'cannot transition if order email is blank' do + order.update_columns( + state: 'address', + email: nil + ) + + api_put :next, id: order.to_param, order_token: order.token + expect(response.status).to eq(422) + expect(json_response['error']).to match(/could not be transitioned/) + end + + it 'cannot transition if any line_item becomes unavailable' do + allow_any_instance_of(Order).to receive(:insufficient_stock_lines).and_return(order.line_items) + api_put :next, id: order.to_param, order_token: order.token + expect(response.status).to eq(422) + expect(json_response['error']).to match(Spree.t(:insufficient_quantity, scope: [:api, :order])) + end + + it 'doesnt advance payment state if order has no payment' do + order.update_column(:state, 'payment') + api_put :next, id: order.to_param, order_token: order.token, order: {} + expect(json_response['errors']['base']).to include(Spree.t(:no_payment_found)) + end + + def send_request + api_put :next, id: order.to_param, order_token: order.token + end + + it_behaves_like 'action which loads order using load_order_with_lock' + end + + context "PUT 'advance'" do + let!(:order) { create(:order_with_line_items) } + + it 'continues to advance advances an order while it can move forward' do + expect_any_instance_of(Spree::Order).to receive(:next).exactly(3).times.and_return(true, true, false) + api_put :advance, id: order.to_param, order_token: order.token + end + + it 'returns the order' do + api_put :advance, id: order.to_param, order_token: order.token + expect(json_response['id']).to eq(order.id) + end + + def send_request + api_put :advance, id: order.to_param, order_token: order.token + end + + it_behaves_like 'action which loads order using load_order_with_lock' + end + end +end diff --git a/api/spec/controllers/spree/api/v1/classifications_controller_spec.rb b/api/spec/controllers/spree/api/v1/classifications_controller_spec.rb new file mode 100644 index 00000000000..3a20880b146 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/classifications_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +module Spree + describe Api::V1::ClassificationsController, type: :controller do + let(:taxon) do + taxon = create(:taxon) + + 3.times do + product = create(:product) + product.taxons << taxon + end + taxon + end + + before do + stub_authentication! + end + + context 'as a user' do + it 'cannot change the order of a product' do + api_put :update, taxon_id: taxon, product_id: taxon.products.first, position: 1 + expect(response.status).to eq(401) + end + end + + context 'as an admin' do + sign_in_as_admin! + + let(:last_product) { taxon.products.last } + + it 'can change the order a product' do + classification = taxon.classifications.find_by(product_id: last_product.id) + expect(classification.position).to eq(3) + api_put :update, taxon_id: taxon.id, product_id: last_product.id, position: 0 + expect(response.status).to eq(200) + expect(classification.reload.position).to eq(1) + end + + it 'touches the taxon' do + taxon.update_attributes(updated_at: Time.current - 10.seconds) + taxon_last_updated_at = taxon.updated_at + api_put :update, taxon_id: taxon.id, product_id: last_product.id, position: 0 + taxon.reload + expect(taxon_last_updated_at.to_i).not_to eq(taxon.updated_at.to_i) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/countries_controller_spec.rb b/api/spec/controllers/spree/api/v1/countries_controller_spec.rb new file mode 100644 index 00000000000..dd2d7336dcd --- /dev/null +++ b/api/spec/controllers/spree/api/v1/countries_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +module Spree + describe Api::V1::CountriesController, type: :controller do + render_views + + before do + stub_authentication! + @state = create(:state) + @country = @state.country + end + + it 'gets all countries' do + api_get :index + expect(json_response['countries'].first['iso3']).to eq @country.iso3 + end + + context 'with two countries' do + before { @zambia = create(:country, name: 'Zambia') } + + it 'can view all countries' do + api_get :index + expect(json_response['count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + + it 'can query the results through a paramter' do + api_get :index, q: { name_cont: 'zam' } + expect(json_response['count']).to eq(1) + expect(json_response['countries'].first['name']).to eq @zambia.name + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + end + + it 'includes states' do + api_get :show, id: @country.id + states = json_response['states'] + expect(states.first['name']).to eq @state.name + end + end +end diff --git a/api/spec/controllers/spree/api/v1/credit_cards_controller_spec.rb b/api/spec/controllers/spree/api/v1/credit_cards_controller_spec.rb new file mode 100644 index 00000000000..e9f0f71f0c3 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/credit_cards_controller_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +module Spree + describe Api::V1::CreditCardsController, type: :controller do + render_views + + let!(:admin_user) do + user = Spree.user_class.new(email: 'spree@example.com', id: 1) + user.generate_spree_api_key! + allow(user).to receive(:has_spree_role?).with('admin').and_return(true) + user + end + + let!(:normal_user) do + user = Spree.user_class.new(email: 'spree2@example.com', id: 2) + user.generate_spree_api_key! + user + end + + let!(:card) { create(:credit_card, user_id: admin_user.id, gateway_customer_profile_id: 'random') } + + before do + stub_authentication! + end + + it "the user id doesn't exist" do + api_get :index, user_id: 1000 + expect(response.status).to eq(404) + end + + context 'calling user is in admin role' do + let(:current_api_user) do + user = admin_user + user + end + + it 'no credit cards exist for user' do + api_get :index, user_id: normal_user.id + + expect(response.status).to eq(200) + expect(json_response['pages']).to eq(0) + end + + it 'can view all credit cards for user' do + api_get :index, user_id: current_api_user.id + + expect(response.status).to eq(200) + expect(json_response['pages']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['credit_cards'].length).to eq(1) + expect(json_response['credit_cards'].first['id']).to eq(card.id) + end + end + + context 'calling user is not in admin role' do + let(:current_api_user) do + user = normal_user + user + end + + let!(:card) { create(:credit_card, user_id: normal_user.id, gateway_customer_profile_id: 'random') } + + it 'can not view user' do + api_get :index, user_id: admin_user.id + + expect(response.status).to eq(404) + end + + it 'can view own credit cards' do + api_get :index, user_id: normal_user.id + + expect(response.status).to eq(200) + expect(json_response['pages']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['credit_cards'].length).to eq(1) + expect(json_response['credit_cards'].first['id']).to eq(card.id) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/customer_returns_controller_spec.rb b/api/spec/controllers/spree/api/v1/customer_returns_controller_spec.rb new file mode 100644 index 00000000000..78c7d50a2e6 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/customer_returns_controller_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +module Spree + describe Api::V1::CustomerReturnsController, type: :controller do + render_views + + before do + stub_authentication! + @customer_return = create(:customer_return) + end + + describe '#index' do + let(:order) { customer_return.order } + let(:customer_return) { create(:customer_return) } + + before do + api_get :index + end + + it 'loads customer returns' do + expect(response.status).to eq(200) + expect(json_response['count']).to eq(1) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/images_controller_spec.rb b/api/spec/controllers/spree/api/v1/images_controller_spec.rb new file mode 100644 index 00000000000..c877e376375 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/images_controller_spec.rb @@ -0,0 +1,115 @@ +require 'spec_helper' + +module Spree + describe Api::V1::ImagesController, type: :controller do + render_views + + let!(:product) { create(:product) } + let!(:attributes) do + [:id, :position, :attachment_content_type, + :attachment_file_name, :type, :attachment_updated_at, :attachment_width, + :attachment_height, :alt] + end + + before do + stub_authentication! + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can learn how to create a new image' do + api_get :new, product_id: product.id + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['required_attributes']).to be_empty + end + + it 'can upload a new image for a variant' do + expect do + api_post :create, + image: { attachment: upload_image('thinking-cat.jpg'), + viewable_type: 'Spree::Variant', + viewable_id: product.master.to_param }, + product_id: product.id + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + end.to change(Image, :count).by(1) + end + + it "can't upload a new image for a variant without attachment" do + api_post :create, + image: { viewable_type: 'Spree::Variant', + viewable_id: product.master.to_param }, + product_id: product.id + expect(response.status).to eq(422) + end + + context 'working with an existing image' do + let!(:product_image) { create_image(product.master, image('thinking-cat.jpg')) } + + it 'can get a single product image' do + api_get :show, id: product_image.id, product_id: product.id + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + end + + it 'can get a single variant image' do + api_get :show, id: product_image.id, variant_id: product.master.id + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + end + + it 'can get a list of product images' do + api_get :index, product_id: product.id + expect(response.status).to eq(200) + expect(json_response).to have_key('images') + expect(json_response['images'].first).to have_attributes(attributes) + end + + it 'can get a list of variant images' do + api_get :index, variant_id: product.master.id + expect(response.status).to eq(200) + expect(json_response).to have_key('images') + expect(json_response['images'].first).to have_attributes(attributes) + end + + it 'can update image data' do + expect(product_image.position).to eq(1) + api_post :update, image: { position: 2 }, id: product_image.id, product_id: product.id + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + expect(product_image.reload.position).to eq(2) + end + + it "can't update an image without attachment" do + api_post :update, + id: product_image.id, product_id: product.id + expect(response.status).to eq(422) + end + + it 'can delete an image' do + api_delete :destroy, id: product_image.id, product_id: product.id + expect(response.status).to eq(204) + expect { product_image.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context 'as a non-admin' do + it 'cannot create an image' do + api_post :create, product_id: product.id + assert_unauthorized! + end + + it 'cannot update an image' do + api_put :update, id: 1, product_id: product.id + assert_not_found! + end + + it 'cannot delete an image' do + api_delete :destroy, id: 1, product_id: product.id + assert_not_found! + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/inventory_units_controller_spec.rb b/api/spec/controllers/spree/api/v1/inventory_units_controller_spec.rb new file mode 100644 index 00000000000..096dd8fe00b --- /dev/null +++ b/api/spec/controllers/spree/api/v1/inventory_units_controller_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +module Spree + describe Api::V1::InventoryUnitsController, type: :controller do + render_views + + before do + stub_authentication! + @inventory_unit = create(:inventory_unit) + end + + context 'as an admin' do + sign_in_as_admin! + + it 'gets an inventory unit' do + api_get :show, id: @inventory_unit.id + expect(json_response['state']).to eq @inventory_unit.state + end + + it 'updates an inventory unit' do + api_put :update, id: @inventory_unit.id, + inventory_unit: { shipment_id: nil } + expect(json_response['shipment_id']).to be_nil + end + + context 'fires state event' do + it 'if supplied with :fire param' do + api_put :update, id: @inventory_unit.id, + fire: 'ship', + inventory_unit: { shipment: { tracking: 'foobar' } } + expect(json_response['state']).to eq 'shipped' + end + + it 'and returns exception if cannot fire' do + api_put :update, id: @inventory_unit.id, + fire: 'return' + expect(json_response['exception']).to match(/cannot transition to return/) + end + + it 'and returns exception bad state' do + api_put :update, id: @inventory_unit.id, + fire: 'bad' + expect(json_response['exception']).to match(/cannot transition to bad/) + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/line_items_controller_spec.rb b/api/spec/controllers/spree/api/v1/line_items_controller_spec.rb new file mode 100644 index 00000000000..881f0a5da02 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/line_items_controller_spec.rb @@ -0,0 +1,206 @@ +require 'spec_helper' + +module Spree + PermittedAttributes.module_eval do + mattr_writer :line_item_attributes + end + + unless PermittedAttributes.line_item_attributes.include?(:some_option) + PermittedAttributes.line_item_attributes += [:some_option] + end + + # This should go in an initializer + Spree::Api::V1::LineItemsController.line_item_options += [:some_option] + + describe Api::V1::LineItemsController, type: :controller do + render_views + + let!(:order) { create(:order_with_line_items, line_items_count: 1) } + + let(:product) { create(:product) } + let(:attributes) { [:id, :quantity, :price, :variant, :total, :display_amount, :single_display_amount] } + let(:resource_scoping) { { order_id: order.to_param } } + let(:admin_role) { create(:admin_role) } + + before do + stub_authentication! + end + + it 'can learn how to create a new line item' do + api_get :new + expect(json_response['attributes']).to eq(['quantity', 'price', 'variant_id']) + required_attributes = json_response['required_attributes'] + expect(required_attributes).to include('quantity', 'variant_id') + end + + context 'authenticating with a token' do + it 'can add a new line item to an existing order' do + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 }, order_token: order.token + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response['variant']['name']).not_to be_blank + end + + it 'can add a new line item to an existing order with token in header' do + request.headers['X-Spree-Order-Token'] = order.token + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response['variant']['name']).not_to be_blank + end + end + + context 'as the order owner' do + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + end + + it 'can add a new line item to an existing order' do + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response['variant']['name']).not_to be_blank + end + + it 'can add a new line item to an existing order with options' do + expect_any_instance_of(LineItem).to receive(:some_option=).with('foo') + api_post :create, + line_item: { + variant_id: product.master.to_param, + quantity: 1, + options: { some_option: 'foo' } + } + expect(response.status).to eq(201) + end + + it 'default quantity to 1 if none is given' do + api_post :create, line_item: { variant_id: product.master.to_param } + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response[:quantity]).to eq 1 + end + + it "increases a line item's quantity if it exists already" do + order.line_items.create(variant_id: product.master.id, quantity: 10) + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + expect(response.status).to eq(201) + order.reload + expect(order.line_items.count).to eq(2) # 1 original due to factory, + 1 in this test + expect(json_response).to have_attributes(attributes) + expect(json_response['quantity']).to eq(11) + end + + it 'can update a line item on the order' do + line_item = order.line_items.first + api_put :update, id: line_item.id, line_item: { quantity: 101 } + expect(response.status).to eq(200) + order.reload + expect(order.total).to eq(1010) # 10 original due to factory, + 1000 in this test + expect(json_response).to have_attributes(attributes) + expect(json_response['quantity']).to eq(101) + end + + it "can update a line item's options on the order" do + expect_any_instance_of(LineItem).to receive(:some_option=).with('foo') + line_item = order.line_items.first + api_put :update, + id: line_item.id, + line_item: { quantity: 1, options: { some_option: 'foo' } } + expect(response.status).to eq(200) + end + + it 'can delete a line item on the order' do + line_item = order.line_items.first + api_delete :destroy, id: line_item.id + expect(response.status).to eq(204) + order.reload + expect(order.line_items.count).to eq(0) # 1 original due to factory, - 1 in this test + expect { line_item.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'order contents changed after shipments were created' do + let!(:order) { create(:order) } + let!(:line_item) { Spree::Cart::AddItem.call(order: order, variant: product.master).value } + + before { order.create_proposed_shipments } + + it 'clear out shipments on create' do + expect(order.reload.shipments).not_to be_empty + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + expect(order.reload.shipments).to be_empty + end + + it 'clear out shipments on update' do + expect(order.reload.shipments).not_to be_empty + api_put :update, id: line_item.id, line_item: { quantity: 1000 } + expect(order.reload.shipments).to be_empty + end + + it 'clear out shipments on delete' do + expect(order.reload.shipments).not_to be_empty + api_delete :destroy, id: line_item.id + expect(order.reload.shipments).to be_empty + end + + context 'order is completed' do + before do + current_api_user.spree_roles << admin_role + order.reload + allow(order).to receive_messages completed?: true + allow(Order).to receive_message_chain :includes, find_by!: order + end + + it "doesn't destroy shipments or restart checkout flow" do + expect(order.reload.shipments).not_to be_empty + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + expect(order.reload.shipments).not_to be_empty + end + + context 'deleting line items' do + let!(:shipments) { order.shipments.load } + + it 'restocks product after line item removal' do + line_item = order.line_items.first + variant = line_item.variant + expect do + api_delete :destroy, id: line_item.id + end.to change { variant.total_on_hand }.by(line_item.quantity) + + expect(response.status).to eq(204) + order.reload + expect(order.line_items.count).to eq(0) + end + + it 'calls `restock` on proper stock location' do + expect(shipments.first.stock_location).to receive(:restock) + api_delete :destroy, id: line_item.id + end + end + end + end + end + + context 'as just another user' do + before { create(:user) } + + it 'cannot add a new line item to the order' do + api_post :create, line_item: { variant_id: product.master.to_param, quantity: 1 } + assert_unauthorized! + end + + it 'cannot update a line item on the order' do + line_item = order.line_items.first + api_put :update, id: line_item.id, line_item: { quantity: 1000 } + assert_unauthorized! + expect(line_item.reload.quantity).not_to eq(1000) + end + + it 'cannot delete a line item on the order' do + line_item = order.line_items.first + api_delete :destroy, id: line_item.id + assert_unauthorized! + expect { line_item.reload }.not_to raise_error + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/option_types_controller_spec.rb b/api/spec/controllers/spree/api/v1/option_types_controller_spec.rb new file mode 100644 index 00000000000..0a2e520957d --- /dev/null +++ b/api/spec/controllers/spree/api/v1/option_types_controller_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +module Spree + describe Api::V1::OptionTypesController, type: :controller do + render_views + + let(:attributes) { [:id, :name, :presentation, :position] } + let!(:option_value) { create(:option_value) } + let!(:option_type) { option_value.option_type } + + before do + stub_authentication! + end + + def check_option_values(option_values) + expect(option_values.count).to eq(1) + expect(option_values.first).to have_attributes([:id, :name, :presentation, + :option_type_id, :option_type_name]) + end + + it 'can list all option types' do + api_get :index + expect(json_response.count).to eq(1) + expect(json_response.first).to have_attributes(attributes) + + check_option_values(json_response.first['option_values']) + end + + it 'can search for an option type' do + create(:option_type, name: 'buzz') + api_get :index, q: { name_cont: option_type.name } + expect(json_response.count).to eq(1) + expect(json_response.first).to have_attributes(attributes) + end + + it 'can retrieve a list of specific option types' do + option_type_1 = create(:option_type) + create(:option_type) # option_type_2 + + api_get :index, ids: "#{option_type.id},#{option_type_1.id}" + expect(json_response.count).to eq(2) + + check_option_values(json_response.first['option_values']) + end + + it 'can list a single option type' do + api_get :show, id: option_type.id + expect(json_response).to have_attributes(attributes) + check_option_values(json_response['option_values']) + end + + it 'can learn how to create a new option type' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['required_attributes']).not_to be_empty + end + + it 'cannot create a new option type' do + api_post :create, option_type: { + name: 'Option Type', + presentation: 'Option Type' + } + assert_unauthorized! + end + + it 'cannot alter an option type' do + original_name = option_type.name + api_put :update, id: option_type.id, + option_type: { + name: 'Option Type' + } + assert_not_found! + expect(option_type.reload.name).to eq(original_name) + end + + it 'cannot delete an option type' do + api_delete :destroy, id: option_type.id + assert_not_found! + expect { option_type.reload }.not_to raise_error + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can create an option type' do + api_post :create, option_type: { + name: 'Option Type', + presentation: 'Option Type' + } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + end + + it 'cannot create an option type with invalid attributes' do + api_post :create, option_type: {} + expect(response.status).to eq(422) + end + + it 'can update an option type' do + api_put :update, id: option_type.id, option_type: { name: 'Option Type' } + expect(response.status).to eq(200) + expect(option_type.reload.name).to eq('Option Type') + end + + it 'cannot update an option type with invalid attributes' do + api_put :update, id: option_type.id, option_type: { name: '' } + expect(response.status).to eq(422) + end + + it 'can delete an option type' do + api_delete :destroy, id: option_type.id + expect(response.status).to eq(204) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/option_values_controller_spec.rb b/api/spec/controllers/spree/api/v1/option_values_controller_spec.rb new file mode 100644 index 00000000000..83bcf0819bf --- /dev/null +++ b/api/spec/controllers/spree/api/v1/option_values_controller_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' + +module Spree + describe Api::V1::OptionValuesController, type: :controller do + render_views + + let(:attributes) { [:id, :name, :presentation, :option_type_name, :option_type_id, :option_type_presentation] } + let!(:option_value) { create(:option_value) } + let!(:option_type) { option_value.option_type } + + before do + stub_authentication! + end + + def check_option_values(option_values) + expect(option_values.count).to eq(1) + expect(option_values.first).to have_attributes([:id, :name, :presentation, + :option_type_name, :option_type_id]) + end + + context 'without any option type scoping' do + before do + # Create another option value with a brand new option type + create(:option_value, option_type: create(:option_type)) + end + + it 'can retrieve a list of all option values' do + api_get :index + expect(json_response.count).to eq(2) + expect(json_response.first).to have_attributes(attributes) + end + end + + context 'for a particular option type' do + let(:resource_scoping) { { option_type_id: option_type.id } } + + it 'can list all option values' do + api_get :index + expect(json_response.count).to eq(1) + expect(json_response.first).to have_attributes(attributes) + end + + it 'can search for an option type' do + create(:option_value, name: 'buzz') + api_get :index, q: { name_cont: option_value.name } + expect(json_response.count).to eq(1) + expect(json_response.first).to have_attributes(attributes) + end + + it 'can retrieve a list of option types' do + option_value_1 = create(:option_value, option_type: option_type) + create(:option_value, option_type: option_type) # option_value_2 + api_get :index, ids: [option_value.id, option_value_1.id] + expect(json_response.count).to eq(2) + end + + it 'can list a single option value' do + api_get :show, id: option_value.id + expect(json_response).to have_attributes(attributes) + end + + it 'cannot create a new option value' do + api_post :create, option_value: { + name: 'Option Value', + presentation: 'Option Value' + } + assert_unauthorized! + end + + it 'cannot alter an option value' do + original_name = option_type.name + api_put :update, id: option_type.id, + option_value: { + name: 'Option Value' + } + assert_not_found! + expect(option_type.reload.name).to eq(original_name) + end + + it 'cannot delete an option value' do + api_delete :destroy, id: option_type.id + assert_not_found! + expect { option_type.reload }.not_to raise_error + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can learn how to create a new option value' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['required_attributes']).not_to be_empty + end + + it 'can create an option value' do + api_post :create, option_value: { + name: 'Option Value', + presentation: 'Option Value' + } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + end + + it 'cannot create an option type with invalid attributes' do + api_post :create, option_value: {} + expect(response.status).to eq(422) + end + + it 'can update an option value' do + api_put :update, id: option_value.id, option_value: { name: 'Option Value' } + expect(response.status).to eq(200) + expect(option_value.reload.name).to eq('Option Value') + end + + it 'permits the correct attributes' do + expect(controller).to receive(:permitted_option_value_attributes) + api_put :update, id: option_value.id, option_value: { name: '' } + end + + it 'cannot update an option value with invalid attributes' do + api_put :update, id: option_value.id, option_value: { name: '' } + expect(response.status).to eq(422) + end + + it 'can delete an option value' do + api_delete :destroy, id: option_value.id + expect(response.status).to eq(204) + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/orders_controller_spec.rb b/api/spec/controllers/spree/api/v1/orders_controller_spec.rb new file mode 100755 index 00000000000..fb762560075 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/orders_controller_spec.rb @@ -0,0 +1,780 @@ +require 'spec_helper' +require 'spree/testing_support/bar_ability' + +module Spree + describe Api::V1::OrdersController, type: :controller do + render_views + + let!(:order) { create(:order) } + let(:variant) { create(:variant) } + let(:line_item) { create(:line_item) } + + let(:attributes) do + [:number, :item_total, :display_total, :total, :state, :adjustment_total, :user_id, + :created_at, :updated_at, :completed_at, :payment_total, :shipment_state, :payment_state, + :email, :special_instructions, :total_quantity, :display_item_total, :currency, :considered_risky] + end + + let(:address_params) { { country_id: Country.first.id, state_id: State.first.id } } + + let(:current_api_user) do + user = Spree.user_class.new(email: 'spree@example.com') + user.generate_spree_api_key! + user + end + + before do + stub_authentication! + end + + it 'cannot view all orders' do + api_get :index + assert_unauthorized! + end + + context 'the current api user is not persisted' do + let(:current_api_user) { Spree.user_class.new } + + it 'returns a 401' do + api_get :mine + expect(response.status).to eq(401) + end + end + + context 'the current api user is authenticated' do + let(:current_api_user) { order.user } + let(:order) { create(:order, line_items: [line_item]) } + + it 'can view all of their own orders' do + api_get :mine + + expect(response.status).to eq(200) + expect(json_response['pages']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['orders'].length).to eq(1) + expect(json_response['orders'].first['number']).to eq(order.number) + expect(json_response['orders'].first['line_items'].length).to eq(1) + expect(json_response['orders'].first['line_items'].first['id']).to eq(line_item.id) + end + + it 'can filter the returned results' do + api_get :mine, q: { completed_at_not_null: 1 } + + expect(response.status).to eq(200) + expect(json_response['orders'].length).to eq(0) + end + + it 'returns orders in reverse chronological order by completed_at' do + Timecop.scale(3600) do + order.update_columns completed_at: Time.current + + order2 = Order.create user: order.user, completed_at: Time.current - 1.day + expect(order2.created_at).to be > order.created_at + order3 = Order.create user: order.user, completed_at: nil + expect(order3.created_at).to be > order2.created_at + order4 = Order.create user: order.user, completed_at: nil + expect(order4.created_at).to be > order3.created_at + + api_get :mine + expect(response.status).to eq(200) + expect(json_response['pages']).to eq(1) + expect(json_response['orders'].length).to eq(4) + expect(json_response['orders'][0]['number']).to eq(order.number) + expect(json_response['orders'][1]['number']).to eq(order2.number) + expect(json_response['orders'][2]['number']).to eq(order4.number) + expect(json_response['orders'][3]['number']).to eq(order3.number) + end + end + end + + describe 'current' do + subject do + api_get :current, format: 'json' + end + + let(:current_api_user) { order.user } + let!(:order) { create(:order, line_items: [line_item]) } + + context 'an incomplete order exists' do + it 'returns that order' do + expect(JSON.parse(subject.body)['id']).to eq order.id + expect(subject).to be_successful + end + end + + context 'multiple incomplete orders exist' do + it 'returns the latest incomplete order' do + Timecop.scale(3600) do + new_order = Spree::Order.create! user: order.user + expect(new_order.created_at).to be > order.created_at + expect(JSON.parse(subject.body)['id']).to eq new_order.id + end + end + end + + context 'an incomplete order does not exist' do + before do + order.update_attribute(:state, order_state) + order.update_attribute(:completed_at, 5.minutes.ago) + end + + ['complete', 'returned', 'awaiting_return'].each do |order_state| + context "order is in the #{order_state} state" do + let(:order_state) { order_state } + + it 'returns no content' do + expect(subject.status).to eq 204 + expect(subject.body).to be_blank + end + end + end + end + end + + it 'can view their own order' do + allow_any_instance_of(Order).to receive_messages user: current_api_user + api_get :show, id: order.to_param + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + expect(json_response['adjustments']).to be_empty + end + + describe 'GET #show' do + subject { api_get :show, id: order.to_param } + + let(:order) { create :order_with_line_items } + let(:adjustment) { FactoryBot.create(:adjustment, order: order) } + + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + end + + context 'when inventory information is present' do + it 'contains stock information on variant' do + subject + variant = json_response['line_items'][0]['variant'] + expect(variant).not_to be_nil + expect(variant['in_stock']).to eq(false) + expect(variant['total_on_hand']).to eq(0) + expect(variant['is_backorderable']).to eq(true) + expect(variant['is_destroyed']).to eq(false) + end + end + + context 'when shipment adjustments are present' do + before do + order.shipments.first.adjustments << adjustment + end + + it 'contains adjustments on shipment' do + subject + + # Test to insure shipment has adjustments + shipment = json_response['shipments'][0] + expect(shipment).not_to be_nil + expect(shipment['adjustments'][0]).not_to be_empty + expect(shipment['adjustments'][0]['label']).to eq(adjustment.label) + end + end + end + + it 'order contains the basic checkout steps' do + allow_any_instance_of(Order).to receive_messages user: current_api_user + api_get :show, id: order.to_param + expect(response.status).to eq(200) + expect(json_response['checkout_steps']).to eq(['address', 'delivery', 'complete']) + end + + # Regression test for #1992 + it 'can view an order not in a standard state' do + allow_any_instance_of(Order).to receive_messages user: current_api_user + order.update_column(:state, 'shipped') + api_get :show, id: order.to_param + end + + it "can not view someone else's order" do + allow_any_instance_of(Order).to receive_messages user: stub_model(Spree::LegacyUser) + api_get :show, id: order.to_param + assert_unauthorized! + end + + it 'can view an order if the token is known' do + api_get :show, id: order.to_param, order_token: order.token + expect(response.status).to eq(200) + end + + it 'can view an order if the token is passed in header' do + request.headers['X-Spree-Order-Token'] = order.token + api_get :show, id: order.to_param + expect(response.status).to eq(200) + end + + context 'with BarAbility registered' do + before { Spree::Ability.register_ability(::BarAbility) } + + after { Spree::Ability.remove_ability(::BarAbility) } + + it 'can view an order' do + user = mock_model(Spree::LegacyUser) + allow(user).to receive_message_chain(:spree_roles, :pluck).and_return(['bar']) + allow(user).to receive(:has_spree_role?).with('bar').and_return(true) + allow(user).to receive(:has_spree_role?).with('admin').and_return(false) + allow(Spree.user_class).to receive_messages find_by: user + api_get :show, id: order.to_param + expect(response.status).to eq(200) + end + end + + it "cannot cancel an order that doesn't belong to them" do + order.update_attribute(:completed_at, Time.current) + order.update_attribute(:shipment_state, 'ready') + api_put :cancel, id: order.to_param + assert_unauthorized! + end + + it 'can create an order' do + api_post :create, order: { line_items: { '0' => { variant_id: variant.to_param, quantity: 5 } } } + expect(response.status).to eq(201) + + order = Order.last + expect(order.line_items.count).to eq(1) + expect(order.line_items.first.variant).to eq(variant) + expect(order.line_items.first.quantity).to eq(5) + + expect(json_response['number']).to be_present + expect(json_response['token']).not_to be_blank + expect(json_response['state']).to eq('cart') + expect(order.user).to eq(current_api_user) + expect(order.email).to eq(current_api_user.email) + expect(json_response['user_id']).to eq(current_api_user.id) + end + + it 'assigns email when creating a new order' do + api_post :create, order: { email: 'guest@spreecommerce.org' } + expect(json_response['email']).not_to eq controller.current_api_user + expect(json_response['email']).to eq 'guest@spreecommerce.org' + end + + it 'cannot arbitrarily set the line items price' do + api_post :create, order: { + line_items: { '0' => { price: 33.0, variant_id: variant.to_param, quantity: 5 } } + } + + expect(response.status).to eq 201 + expect(Order.last.line_items.first.price.to_f).to eq(variant.price) + end + + context 'admin user imports order' do + before do + allow(current_api_user).to receive_messages has_spree_role?: true + allow(current_api_user).to receive_message_chain :spree_roles, pluck: ['admin'] + end + + it 'is able to set any default unpermitted attribute' do + api_post :create, order: { number: 'WOW' } + expect(response.status).to eq 201 + expect(json_response['number']).to eq 'WOW' + end + end + + it 'can create an order without any parameters' do + expect { api_post :create }.not_to raise_error + expect(response.status).to eq(201) + expect(json_response['state']).to eq('cart') + end + + context 'working with an order' do + let(:variant) { create(:variant) } + let!(:line_item) { Spree::Cart::AddItem.call(order: order, variant: variant).value } + let(:address_params) { { country_id: country.id } } + let(:billing_address) do + { + firstname: 'Tiago', lastname: 'Motta', address1: 'Av Paulista', + city: 'Sao Paulo', zipcode: '01310-300', phone: '12345678', + country_id: country.id + } + end + let(:shipping_address) do + { + firstname: 'Tiago', lastname: 'Motta', address1: 'Av Paulista', + city: 'Sao Paulo', zipcode: '01310-300', phone: '12345678', + country_id: country.id + } + end + let(:country) { create(:country, name: 'Brazil', iso_name: 'BRAZIL', iso: 'BR', iso3: 'BRA', numcode: 76) } + + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + order.next # Switch from cart to address + order.bill_address = nil + order.ship_address = nil + order.save + expect(order.state).to eq('address') + end + + def clean_address(address) + address.delete(:state) + address.delete(:country) + address + end + + context 'line_items hash not present in request' do + it 'responds successfully' do + api_put :update, id: order.to_param, order: { + email: 'hublock@spreecommerce.com' + } + + expect(response).to be_successful + end + end + + it 'updates quantities of existing line items' do + api_put :update, id: order.to_param, order: { + line_items: { + '0' => { id: line_item.id, quantity: 10 } + } + } + + expect(response.status).to eq(200) + expect(json_response['line_items'].count).to eq(1) + expect(json_response['line_items'].first['quantity']).to eq(10) + end + + it 'adds an extra line item' do + variant2 = create(:variant) + api_put :update, id: order.to_param, order: { + line_items: { + '0' => { id: line_item.id, quantity: 10 }, + '1' => { variant_id: variant2.id, quantity: 1 } + } + } + + expect(response.status).to eq(200) + expect(json_response['line_items'].count).to eq(2) + expect(json_response['line_items'][0]['quantity']).to eq(10) + expect(json_response['line_items'][1]['variant_id']).to eq(variant2.id) + expect(json_response['line_items'][1]['quantity']).to eq(1) + end + + it 'cannot change the price of an existing line item' do + api_put :update, id: order.to_param, order: { + line_items: { + 0 => { id: line_item.id, price: 0 } + } + } + + expect(response.status).to eq(200) + expect(json_response['line_items'].count).to eq(1) + expect(json_response['line_items'].first['price'].to_f).not_to eq(0) + expect(json_response['line_items'].first['price'].to_f).to eq(line_item.variant.price) + end + + it 'can add billing address' do + api_put :update, id: order.to_param, order: { bill_address_attributes: billing_address } + + expect(order.reload.bill_address).not_to be_nil + end + + it 'receives error message if trying to add billing address with errors' do + billing_address[:firstname] = '' + + api_put :update, id: order.to_param, order: { bill_address_attributes: billing_address } + + expect(json_response['error']).not_to be_nil + expect(json_response['errors']).not_to be_nil + expect(json_response['errors']['bill_address.firstname'].first).to eq "can't be blank" + end + + it 'can add shipping address' do + expect(order.ship_address).to be_nil + + api_put :update, id: order.to_param, order: { ship_address_attributes: shipping_address } + + expect(order.reload.ship_address).not_to be_nil + end + + it 'receives error message if trying to add shipping address with errors' do + expect(order.ship_address).to be_nil + shipping_address[:firstname] = '' + + api_put :update, id: order.to_param, order: { ship_address_attributes: shipping_address } + + expect(json_response['error']).not_to be_nil + expect(json_response['errors']).not_to be_nil + expect(json_response['errors']['ship_address.firstname'].first).to eq "can't be blank" + end + + it 'can set the user_id for the order' do + user = Spree.user_class.create + api_post :update, id: order.to_param, order: { user_id: user.id } + expect(response.status).to eq 200 + expect(json_response['user_id']).to eq(user.id) + end + + context 'order has shipments' do + before { order.create_proposed_shipments } + + it 'clears out all existing shipments on line item udpate' do + api_put :update, id: order.to_param, order: { + line_items: { + 0 => { id: line_item.id, quantity: 10 } + } + } + expect(order.reload.shipments).to be_empty + end + end + + context 'with a line item' do + let(:order_with_line_items) do + order = create(:order_with_line_items) + create(:adjustment, order: order, adjustable: order) + order + end + + it 'can empty an order' do + expect(order_with_line_items.adjustments.count).to eq(1) + api_put :empty, id: order_with_line_items.to_param + expect(response.status).to eq(204) + order_with_line_items.reload + expect(order_with_line_items.line_items).to be_empty + expect(order_with_line_items.adjustments).to be_empty + end + + it 'can list its line items with images' do + create_image(order.line_items.first.variant, image('thinking-cat.jpg')) + + api_get :show, id: order.to_param + + expect(json_response['line_items'].first['variant']).to have_attributes([:images]) + end + + it 'lists variants product id' do + api_get :show, id: order.to_param + + expect(json_response['line_items'].first['variant']).to have_attributes([:product_id]) + end + + it 'includes the tax_total in the response' do + api_get :show, id: order.to_param + + expect(json_response['included_tax_total']).to eq('0.0') + expect(json_response['additional_tax_total']).to eq('0.0') + expect(json_response['display_included_tax_total']).to eq('$0.00') + expect(json_response['display_additional_tax_total']).to eq('$0.00') + end + + it 'lists line item adjustments' do + adjustment = create(:adjustment, + label: '10% off!', + order: order, + adjustable: order.line_items.first) + adjustment.update_column(:amount, 5) + api_get :show, id: order.to_param + + adjustment = json_response['line_items'].first['adjustments'].first + expect(adjustment['label']).to eq('10% off!') + expect(adjustment['amount']).to eq('5.0') + end + + it 'lists payments source without gateway info' do + order.payments.push payment = create(:payment) + api_get :show, id: order.to_param + + source = json_response[:payments].first[:source] + expect(source[:name]).to eq payment.source.name + expect(source[:cc_type]).to eq payment.source.cc_type + expect(source[:last_digits]).to eq payment.source.last_digits + expect(source[:month].to_i).to eq payment.source.month + expect(source[:year].to_i).to eq payment.source.year + expect(source.key?(:gateway_customer_profile_id)).to be false + expect(source.key?(:gateway_payment_profile_id)).to be false + end + + context 'when in delivery' do + let!(:shipping_method) do + FactoryBot.create(:shipping_method).tap do |shipping_method| + shipping_method.calculator.preferred_amount = 10 + shipping_method.calculator.save + end + end + + before do + order.bill_address = FactoryBot.create(:address) + order.ship_address = FactoryBot.create(:address) + order.next! + order.save + end + + it 'includes the ship_total in the response' do + api_get :show, id: order.to_param + + expect(json_response['ship_total']).to eq '10.0' + expect(json_response['display_ship_total']).to eq '$10.00' + end + + it 'returns available shipments for an order' do + api_get :show, id: order.to_param + expect(response.status).to eq(200) + expect(json_response['shipments']).not_to be_empty + shipment = json_response['shipments'][0] + # Test for correct shipping method attributes + # Regression test for #3206 + expect(shipment['shipping_methods']).not_to be_nil + json_shipping_method = shipment['shipping_methods'][0] + expect(json_shipping_method['id']).to eq(shipping_method.id) + expect(json_shipping_method['name']).to eq(shipping_method.name) + expect(json_shipping_method['code']).to eq(shipping_method.code) + expect(json_shipping_method['zones']).not_to be_empty + expect(json_shipping_method['shipping_categories']).not_to be_empty + + # Test for correct shipping rates attributes + # Regression test for #3206 + expect(shipment['shipping_rates']).not_to be_nil + shipping_rate = shipment['shipping_rates'][0] + expect(shipping_rate['name']).to eq(json_shipping_method['name']) + expect(shipping_rate['cost']).to eq('10.0') + expect(shipping_rate['selected']).to be true + expect(shipping_rate['display_cost']).to eq('$10.00') + expect(shipping_rate['shipping_method_code']).to eq(json_shipping_method['code']) + + expect(shipment['stock_location_name']).not_to be_blank + manifest_item = shipment['manifest'][0] + expect(manifest_item['quantity']).to eq(1) + expect(manifest_item['variant_id']).to eq(order.line_items.first.variant_id) + end + end + end + end + + context 'as an admin' do + sign_in_as_admin! + + context 'with no orders' do + before { Spree::Order.delete_all } + + it 'still returns a root :orders key' do + api_get :index + expect(json_response['orders']).to eq([]) + end + end + + it 'responds with orders updated_at with miliseconds precision' do + if ApplicationRecord.connection.adapter_name == 'Mysql2' + skip 'MySQL does not support millisecond timestamps.' + else + skip 'Probable need to make it call as_json. See https://github.com/rails/rails/commit/0f33d70e89991711ff8b3dde134a61f4a5a0ec06' + end + + api_get :index + milisecond = order.updated_at.strftime('%L') + updated_at = json_response['orders'].first['updated_at'] + expect(updated_at.split('T').last).to have_content(milisecond) + end + + context 'caching enabled' do + before do + ActionController::Base.perform_caching = true + create_list(:order, 3) + end + + after { ActionController::Base.perform_caching = false } + + it 'returns unique orders' do + api_get :index + + orders = json_response[:orders] + expect(orders.count).to be >= 3 + expect(orders.map { |o| o[:id] }).to match_array Order.pluck(:id) + end + end + + it 'lists payments source with gateway info' do + order.payments.push payment = create(:payment) + api_get :show, id: order.to_param + + source = json_response[:payments].first[:source] + expect(source[:name]).to eq payment.source.name + expect(source[:cc_type]).to eq payment.source.cc_type + expect(source[:last_digits]).to eq payment.source.last_digits + expect(source[:month].to_i).to eq payment.source.month + expect(source[:year].to_i).to eq payment.source.year + expect(source[:gateway_customer_profile_id]).to eq payment.source.gateway_customer_profile_id + expect(source[:gateway_payment_profile_id]).to eq payment.source.gateway_payment_profile_id + end + + context 'with two orders' do + before { create(:order) } + + it 'can view all orders' do + api_get :index + expect(json_response['orders'].first).to have_attributes(attributes) + expect(json_response['count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + + # Test for #1763 + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['orders'].count).to eq(1) + expect(json_response['orders'].first).to have_attributes(attributes) + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + end + + context 'search' do + before do + create(:order) + Spree::Order.last.update_attribute(:email, 'spree@spreecommerce.com') + end + + let(:expected_result) { Spree::Order.last } + + it 'can query the results through a parameter' do + api_get :index, q: { email_cont: 'spree' } + expect(json_response['orders'].count).to eq(1) + expect(json_response['orders'].first).to have_attributes(attributes) + expect(json_response['orders'].first['email']).to eq(expected_result.email) + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + end + + context 'creation' do + it 'can create an order without any parameters' do + expect { api_post :create }.not_to raise_error + expect(response.status).to eq(201) + expect(json_response['state']).to eq('cart') + end + + it 'can arbitrarily set the line items price' do + api_post :create, order: { + line_items: [{ price: 33.0, variant_id: variant.to_param, quantity: 5 }] + } + expect(response.status).to eq 201 + expect(Order.last.line_items.first.price.to_f).to eq(33.0) + end + + it 'can set the user_id for the order' do + user = Spree.user_class.create + api_post :create, order: { user_id: user.id } + expect(response.status).to eq 201 + expect(json_response['user_id']).to eq(user.id) + end + end + + context 'updating' do + it 'can set the user_id for the order' do + user = Spree.user_class.create + api_post :update, id: order.number, order: { user_id: user.id } + expect(response.status).to eq 200 + expect(json_response['user_id']).to eq(user.id) + end + end + + context 'can cancel an order' do + before do + order.completed_at = Time.current + order.state = 'complete' + order.shipment_state = 'ready' + order.save! + end + + specify do + api_put :cancel, id: order.to_param + expect(json_response['state']).to eq('canceled') + expect(json_response['canceler_id']).to eq(current_api_user.id) + end + end + + context 'can approve an order' do + before do + order.completed_at = Time.current + order.state = 'complete' + order.shipment_state = 'ready' + order.considered_risky = true + order.save! + end + + specify do + api_put :approve, id: order.to_param + order.reload + expect(order.approver_id).to eq(current_api_user.id) + expect(order.considered_risky).to eq(false) + end + end + end + + context 'PUT remove_coupon_code' do + let(:order) { create(:order_with_line_items) } + + it 'returns 404 status if promotion does not exist' do + api_put :remove_coupon_code, id: order.number, + order_token: order.token, + coupon_code: 'example' + + expect(response.status).to eq 404 + end + + context 'order with discount promotion' do + let!(:discount_promo_code) { 'discount' } + let!(:discount_promotion) { create(:promotion_with_order_adjustment, code: discount_promo_code) } + let(:order_with_discount_promotion) do + create(:order_with_line_items, coupon_code: discount_promo_code).tap do |order| + Spree::PromotionHandler::Coupon.new(order).apply + end + end + + it 'removes all order adjustments from order and return status 200' do + expect(order_with_discount_promotion.reload.total.to_f).to eq 100.0 + + api_put :remove_coupon_code, id: order_with_discount_promotion.number, + order_token: order_with_discount_promotion.token, + coupon_code: order_with_discount_promotion.coupon_code + + expect(response.status).to eq 200 + expect(json_response['success']).to eq Spree.t('adjustments_deleted') + expect(order_with_discount_promotion.reload.total.to_f).to eq 110.0 + end + end + + context 'order with line item discount promotion' do + let!(:line_item_promo_code) { 'line_item_discount' } + let!(:line_item_promotion) { create(:promotion_with_item_adjustment, code: line_item_promo_code) } + let(:order_with_line_item_promotion) do + create(:order_with_line_items, coupon_code: line_item_promo_code).tap do |order| + Spree::PromotionHandler::Coupon.new(order).apply + end + end + + it 'removes line item adjustments from order and return status 200' do + expect(order_with_line_item_promotion.reload.total.to_f).to eq 100.0 + + api_put :remove_coupon_code, id: order_with_line_item_promotion.number, + order_token: order_with_line_item_promotion.token, + coupon_code: order_with_line_item_promotion.coupon_code + + expect(response.status).to eq 200 + expect(json_response['success']).to eq Spree.t('adjustments_deleted') + expect(order_with_line_item_promotion.reload.total.to_f).to eq 110.0 + end + + it 'removes line item adjustments only for promotable line item' do + order_with_line_item_promotion.line_items << create(:line_item, price: 100) + order_with_line_item_promotion.update_with_updater! + + expect(order_with_line_item_promotion.reload.total.to_f).to eq 200.0 + + api_put :remove_coupon_code, id: order_with_line_item_promotion.number, + order_token: order_with_line_item_promotion.token, + coupon_code: order_with_line_item_promotion.coupon_code + + expect(order_with_line_item_promotion.reload.total.to_f).to eq 210.0 + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/payments_controller_spec.rb b/api/spec/controllers/spree/api/v1/payments_controller_spec.rb new file mode 100644 index 00000000000..55e0db7f579 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/payments_controller_spec.rb @@ -0,0 +1,236 @@ +require 'spec_helper' + +module Spree + describe Api::V1::PaymentsController, type: :controller do + render_views + let!(:order) { create(:order) } + let!(:payment) { create(:payment, order: order) } + let!(:attributes) do + [:id, :source_type, :source_id, :amount, :display_amount, + :payment_method_id, :state, :avs_response, + :created_at, :updated_at, :number] + end + + let(:resource_scoping) { { order_id: order.to_param } } + + before do + stub_authentication! + end + + context 'as a user' do + context 'when the order belongs to the user' do + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + end + + it 'can view the payments for their order' do + api_get :index + expect(json_response['payments'].first).to have_attributes(attributes) + end + + it 'can learn how to create a new payment' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['payment_methods']).not_to be_empty + expect(json_response['payment_methods'].first).to have_attributes([:id, :name, :description]) + end + + it 'can create a new payment' do + allow_any_instance_of(Spree::PaymentMethod).to receive(:source_required?).and_return(false) + api_post :create, payment: { payment_method_id: PaymentMethod.first.id, amount: 50 } + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + end + + it "can view a pre-existing payment's details" do + api_get :show, id: payment.to_param + expect(json_response).to have_attributes(attributes) + end + + it 'cannot update a payment' do + api_put :update, id: payment.to_param, payment: { amount: 2.01 } + assert_unauthorized! + end + + it 'cannot authorize a payment' do + api_put :authorize, id: payment.to_param + assert_unauthorized! + end + end + + context 'when the order does not belong to the user' do + before do + allow_any_instance_of(Order).to receive_messages user: stub_model(LegacyUser) + end + + it "cannot view payments for somebody else's order" do + api_get :index, order_id: order.to_param + assert_unauthorized! + end + + it 'can view the payments for an order given the order token' do + api_get :index, order_id: order.to_param, order_token: order.token + expect(json_response['payments'].first).to have_attributes(attributes) + end + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can view the payments on any order' do + api_get :index + expect(response.status).to eq(200) + expect(json_response['payments'].first).to have_attributes(attributes) + end + + context 'multiple payments' do + before { @payment = create(:payment, order: order) } + + it 'can view all payments on an order' do + api_get :index + expect(json_response['count']).to eq(2) + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + end + + context 'for a given payment' do + context 'updating' do + context 'when the state is checkout' do + it 'can update' do + payment.update_attributes(state: 'checkout') + api_put(:update, id: payment.to_param, payment: { amount: 2.01 }) + expect(response.status).to eq(200) + expect(payment.reload.amount).to eq(2.01) + end + end + + context 'when the state is pending' do + it 'can update' do + payment.update_attributes(state: 'pending') + api_put(:update, id: payment.to_param, payment: { amount: 2.01 }) + expect(response.status).to be(200) + expect(payment.reload.amount).to eq(2.01) + end + end + + context 'update fails' do + it 'returns a 422 status when the amount is invalid' do + payment.update_attributes(state: 'pending') + api_put(:update, id: payment.to_param, payment: { amount: 'invalid' }) + expect(response.status).to be(422) + expect(json_response['error']).to eql('Invalid resource. Please fix errors and try again.') + end + + it 'returns a 403 status when the payment is not pending' do + payment.update_attributes(state: 'completed') + api_put(:update, id: payment.to_param, payment: { amount: 2.01 }) + expect(response.status).to be(403) + expect(json_response['error']).to eql('This payment cannot be updated because it is completed.') + end + end + end + + context 'authorizing' do + it 'can authorize' do + api_put :authorize, id: payment.to_param + expect(response.status).to eq(200) + expect(payment.reload.state).to eq('pending') + end + + context 'authorization fails' do + before do + fake_response = double(success?: false, to_s: 'Could not authorize card') + expect_any_instance_of(Spree::Gateway::Bogus).to receive(:authorize).and_return(fake_response) + api_put :authorize, id: payment.to_param + end + + it 'returns a 422 status' do + expect(response.status).to eq(422) + expect(json_response['error']).to eq 'Invalid resource. Please fix errors and try again.' + expect(json_response['errors']['base'][0]).to eq 'Could not authorize card' + end + + it 'does not raise a stack level error' do + skip "Investigate why a payment.reload after the request raises 'stack level too deep'" + expect(payment.reload.state).to eq('failed') + end + end + end + + context 'capturing' do + it 'can capture' do + api_put :capture, id: payment.to_param + expect(response.status).to eq(200) + expect(payment.reload.state).to eq('completed') + end + + context 'capturing fails' do + before do + fake_response = double(success?: false, to_s: 'Insufficient funds') + expect_any_instance_of(Spree::Gateway::Bogus).to receive(:capture).and_return(fake_response) + end + + it 'returns a 422 status' do + api_put :capture, id: payment.to_param + expect(response.status).to eq(422) + expect(json_response['error']).to eq 'Invalid resource. Please fix errors and try again.' + expect(json_response['errors']['base'][0]).to eq 'Insufficient funds' + end + end + end + + context 'purchasing' do + it 'can purchase' do + api_put :purchase, id: payment.to_param + expect(response.status).to eq(200) + expect(payment.reload.state).to eq('completed') + end + + context 'purchasing fails' do + before do + fake_response = double(success?: false, to_s: 'Insufficient funds') + expect_any_instance_of(Spree::Gateway::Bogus).to receive(:purchase).and_return(fake_response) + end + + it 'returns a 422 status' do + api_put :purchase, id: payment.to_param + expect(response.status).to eq(422) + expect(json_response['error']).to eq 'Invalid resource. Please fix errors and try again.' + expect(json_response['errors']['base'][0]).to eq 'Insufficient funds' + end + end + end + + context 'voiding' do + it 'can void' do + api_put :void, id: payment.to_param + expect(response.status).to eq 200 + expect(payment.reload.state).to eq 'void' + end + + context 'voiding fails' do + before do + fake_response = double(success?: false, to_s: 'NO REFUNDS') + expect_any_instance_of(Spree::Gateway::Bogus).to receive(:void).and_return(fake_response) + end + + it 'returns a 422 status' do + api_put :void, id: payment.to_param + expect(response.status).to eq 422 + expect(json_response['error']).to eq 'Invalid resource. Please fix errors and try again.' + expect(json_response['errors']['base'][0]).to eq 'NO REFUNDS' + expect(payment.reload.state).to eq 'checkout' + end + end + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/product_properties_controller_spec.rb b/api/spec/controllers/spree/api/v1/product_properties_controller_spec.rb new file mode 100644 index 00000000000..741e5f9ebb1 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/product_properties_controller_spec.rb @@ -0,0 +1,156 @@ +require 'spec_helper' +require 'shared_examples/protect_product_actions' + +module Spree + describe Api::V1::ProductPropertiesController, type: :controller do + render_views + + let!(:product) { create(:product) } + let!(:property_1) { product.product_properties.create(property_name: 'My Property 1', value: 'my value 1', position: 0) } + + let(:attributes) { [:id, :product_id, :property_id, :value, :property_name] } + let(:resource_scoping) { { product_id: product.to_param } } + + before do + product.product_properties.create(property_name: 'My Property 2', value: 'my value 2', position: 1) # property_2 + stub_authentication! + end + + context 'if product is deleted' do + before do + product.update_column(:deleted_at, 1.day.ago) + end + + it 'can not see a list of product properties' do + api_get :index + expect(response.status).to eq(404) + end + end + + it 'can see a list of all product properties' do + api_get :index + expect(json_response['product_properties'].count).to eq 2 + expect(json_response['product_properties'].first).to have_attributes(attributes) + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['product_properties'].count).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a parameter' do + Spree::ProductProperty.last.update_attribute(:value, 'loose') + property = Spree::ProductProperty.last + api_get :index, q: { value_cont: 'loose' } + expect(json_response['count']).to eq(1) + expect(json_response['product_properties'].first['value']).to eq property.value + end + + it 'can search for product properties' do + product.product_properties.create(property_name: 'Shirt Size') + product.product_properties.create(property_name: 'Shirt Weight') + api_get :index, q: { property_name_cont: 'size' } + expect(json_response['product_properties'].first['property_name']).to eq('Shirt Size') + expect(json_response['product_properties'].first).to have_attributes(attributes) + expect(json_response['count']).to eq(1) + end + + it 'can see a single product_property' do + api_get :show, id: property_1.property_name + expect(json_response).to have_attributes(attributes) + end + + it 'can learn how to create a new product property' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['required_attributes']).to be_empty + end + + it 'cannot create a new product property if not an admin' do + api_post :create, product_property: { property_name: 'My Property 3' } + assert_unauthorized! + end + + it 'cannot update a product property' do + api_put :update, id: property_1.property_name, product_property: { value: 'my value 456' } + assert_unauthorized! + end + + it 'cannot delete a product property' do + api_delete :destroy, id: property_1.to_param, property_name: property_1.property_name + assert_unauthorized! + expect { property_1.reload }.not_to raise_error + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can create a new product property' do + expect do + api_post :create, product_property: { property_name: 'My Property 3', value: 'my value 3' } + end.to change(product.product_properties, :count).by(1) + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + end + + context 'when product property does not exist' do + it 'cannot update product property and responds 404' do + api_put :update, id: 'does not exist', product_property: { value: 'new value' } + expect(response.status).to eq(404) + end + end + + context 'when product property exists' do + context 'when product property is valid' do + it 'responds 200' do + api_put :update, id: property_1.property_name, product_property: { value: 'my value 456' } + expect(response.status).to eq(200) + end + end + + context 'when product property is invalid' do + before do + expect_any_instance_of(Spree::ProductProperty).to receive(:update_attributes).and_return false + end + + it 'responds 422' do + api_put :update, id: property_1.property_name, product_property: { value: 'hello' } + expect(response.status).to eq(422) + end + end + end + + context 'when product property does not exist' do + it 'cannot delete product property and responds 404' do + api_delete :destroy, id: 'does not exist' + expect(response.status).to eq(404) + end + end + + context 'when product property exists' do + it 'can delete a product property' do + api_delete :destroy, id: property_1.property_name + expect(response.status).to eq(204) + expect { property_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context 'with product identified by id' do + let(:resource_scoping) { { product_id: product.id } } + + it 'can see a list of all product properties' do + api_get :index + expect(json_response['product_properties'].count).to eq 2 + expect(json_response['product_properties'].first).to have_attributes(attributes) + end + + it 'can see a single product_property by id' do + api_get :show, id: property_1.id + expect(json_response).to have_attributes(attributes) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/products_controller_spec.rb b/api/spec/controllers/spree/api/v1/products_controller_spec.rb new file mode 100644 index 00000000000..8f017bee926 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/products_controller_spec.rb @@ -0,0 +1,505 @@ +require 'spec_helper' +require 'shared_examples/protect_product_actions' + +module Spree + describe Api::V1::ProductsController, type: :controller do + render_views + + let!(:product) { create(:product) } + let!(:inactive_product) { create(:product, available_on: Time.current.tomorrow, name: 'inactive') } + let(:base_attributes) { Api::ApiHelpers.product_attributes } + let(:show_attributes) { base_attributes.dup.push(:has_variants) } + let(:new_attributes) { base_attributes } + + let(:product_data) do + { name: 'The Other Product', + price: 19.99, + shipping_category_id: create(:shipping_category).id } + end + let(:attributes_for_variant) do + h = attributes_for(:variant).except(:option_values, :product) + h.merge(options: [ + { name: 'size', value: 'small' }, + { name: 'color', value: 'black' } + ]) + end + + before do + stub_authentication! + end + + context 'as a normal user' do + context 'with caching enabled' do + before do + create(:product) # product_2 + ActionController::Base.perform_caching = true + end + + after do + ActionController::Base.perform_caching = false + end + + it 'returns unique products' do + api_get :index + product_ids = json_response['products'].map { |p| p['id'] } + expect(product_ids.uniq.count).to eq(product_ids.count) + end + end + + it 'retrieves a list of products' do + api_get :index + expect(json_response['products'].first).to have_attributes(show_attributes) + expect(json_response['total_count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + it 'retrieves a list of products by id' do + api_get :index, ids: [product.id] + expect(json_response['products'].first).to have_attributes(show_attributes) + expect(json_response['total_count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + context 'product has more than one price' do + before { product.master.prices.create currency: 'EUR', amount: 22 } + + it 'returns distinct products only' do + api_get :index + expect(assigns(:products).map(&:id).uniq).to eq assigns(:products).map(&:id) + end + end + + it 'retrieves a list of products by ids string' do + second_product = create(:product) + api_get :index, ids: [product.id, second_product.id].join(',') + expect(json_response['products'].first).to have_attributes(show_attributes) + expect(json_response['products'][1]).to have_attributes(show_attributes) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + it 'does not return inactive products when queried by ids' do + api_get :index, ids: [inactive_product.id] + expect(json_response['count']).to eq(0) + end + + it 'does not list unavailable products' do + api_get :index + expect(json_response['products'].first['name']).not_to eq('inactive') + end + + context 'pagination' do + before { create(:product) } + + it 'can select the next page of products' do + api_get :index, page: 2, per_page: 1 + expect(json_response['products'].first).to have_attributes(show_attributes) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(2) + expect(json_response['pages']).to eq(2) + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + end + + it 'can search for products' do + create(:product, name: 'The best product in the world') + api_get :index, q: { name_cont: 'best' } + expect(json_response['products'].first).to have_attributes(show_attributes) + expect(json_response['count']).to eq(1) + end + + # regression test for https://github.com/spree/spree/issues/8207 + it 'can sort products by date' do + first_product = create(:product, created_at: Time.current - 1.month) + create(:product, created_at: Time.current) # second_product + api_get :index, q: { s: 'created_at asc' } + expect(json_response['products'].first['id']).to eq(first_product.id) + end + + it 'gets a single product' do + create_image(product.master, image('thinking-cat.jpg')) + create(:variant, product: product) + create_image(product.variants.first, image('thinking-cat.jpg')) + product.set_property('spree', 'rocks') + product.taxons << create(:taxon) + + api_get :show, id: product.to_param + + expect(json_response).to have_attributes(show_attributes) + expect(json_response['variants'].first).to have_attributes([:name, + :is_master, + :price, + :images, + :in_stock]) + + expect(json_response['variants'].first['images'].first).to have_attributes([:attachment_file_name, + :attachment_width, + :attachment_height, + :attachment_content_type, + :mini_url, + :small_url, + :product_url, + :large_url]) + + expect(json_response['product_properties'].first).to have_attributes([:value, + :product_id, + :property_name]) + + expect(json_response['classifications'].first).to have_attributes([:taxon_id, :position, :taxon]) + expect(json_response['classifications'].first['taxon']).to have_attributes([:id, :name, :pretty_name, :permalink, :taxonomy_id, :parent_id]) + end + + context 'tracking is disabled' do + before { Config.track_inventory_levels = false } + + after { Config.track_inventory_levels = true } + + it 'still displays valid json with total_on_hand Float::INFINITY' do + api_get :show, id: product.to_param + expect(response).to be_ok + expect(json_response[:total_on_hand]).to eq nil + end + end + + context 'finds a product by slug first then by id' do + let!(:other_product) { create(:product, slug: 'these-are-not-the-droids-you-are-looking-for') } + + before do + product.update_column(:slug, "#{other_product.id}-and-1-ways") + end + + specify do + api_get :show, id: product.to_param + expect(json_response['slug']).to match(/and-1-ways/) + product.destroy + + api_get :show, id: other_product.id + expect(json_response['slug']).to match(/droids/) + end + end + + it 'cannot see inactive products' do + api_get :show, id: inactive_product.to_param + assert_not_found! + end + + it 'returns a 404 error when it cannot find a product' do + api_get :show, id: 'non-existant' + assert_not_found! + end + + it 'can learn how to create a new product' do + api_get :new + expect(json_response['attributes']).to eq(new_attributes.map(&:to_s)) + required_attributes = json_response['required_attributes'] + expect(required_attributes).to include('name') + expect(required_attributes).to include('price') + expect(required_attributes).to include('shipping_category') + end + + it_behaves_like 'modifying product actions are restricted' + end + + context 'as an admin' do + let(:taxon_1) { create(:taxon) } + let(:taxon_2) { create(:taxon) } + + sign_in_as_admin! + + it 'can see all products' do + api_get :index + expect(json_response['products'].count).to eq(2) + expect(json_response['count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + + # Regression test for #1626 + context 'deleted products' do + before do + create(:product, deleted_at: 1.day.ago) + end + + it 'does not include deleted products' do + api_get :index + expect(json_response['products'].count).to eq(2) + end + + it 'can include deleted products' do + api_get :index, show_deleted: 1 + expect(json_response['products'].count).to eq(3) + end + end + + describe 'creating a product' do + it 'can create a new product' do + api_post :create, product: { name: 'The Other Product', + price: 19.99, + shipping_category_id: create(:shipping_category).id } + expect(json_response).to have_attributes(base_attributes) + expect(response.status).to eq(201) + end + + it 'creates with embedded variants' do + product_data[:variants] = [attributes_for_variant, attributes_for_variant] + + api_post :create, product: product_data + expect(response.status).to eq 201 + + variants = json_response['variants'] + expect(variants.count).to eq(2) + expect(variants.last['option_values'][0]['name']).to eq('small') + expect(variants.last['option_values'][0]['option_type_name']).to eq('size') + + expect(json_response['option_types'].count).to eq(2) # size, color + end + + it 'can create a new product with embedded product_properties' do + product_data[:product_properties_attributes] = [{ + property_name: 'fabric', + value: 'cotton' + }] + + api_post :create, product: product_data + + expect(json_response['product_properties'][0]['property_name']).to eq('fabric') + expect(json_response['product_properties'][0]['value']).to eq('cotton') + end + + it 'can create a new product with option_types' do + product_data[:option_types] = ['size', 'color'] + + api_post :create, product: product_data + expect(json_response['option_types'].count).to eq(2) + end + + it 'creates product with option_types ids' do + option_type = create(:option_type) + product_data[:option_type_ids] = [option_type.id] + api_post :create, product: product_data + expect(json_response['option_types'].first['id']).to eq option_type.id + end + + it 'creates with shipping categories' do + hash = { name: 'The Other Product', + price: 19.99, + shipping_category: 'Free Ships' } + + api_post :create, product: hash + expect(response.status).to eq 201 + + shipping_id = ShippingCategory.find_by(name: 'Free Ships').id + expect(json_response['shipping_category_id']).to eq shipping_id + end + + it 'puts the created product in the given taxons' do + product_data[:taxon_ids] = [taxon_1.id, taxon_2.id] + api_post :create, product: product_data + expect(json_response['taxon_ids']).to eq([taxon_1.id, taxon_2.id]) + end + + # Regression test for #2140 + context 'with authentication_required set to false' do + before do + Spree::Api::Config.requires_authentication = false + end + + after do + Spree::Api::Config.requires_authentication = true + end + + it 'can still create a product' do + api_post :create, product: product_data, token: 'fake' + expect(json_response).to have_attributes(show_attributes) + expect(response.status).to eq(201) + end + end + + it 'cannot create a new product with invalid attributes' do + api_post :create, product: { foo: :bar } + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + errors = json_response['errors'] + errors.delete('slug') # Don't care about this one. + expect(errors.keys).to match_array(['name', 'price', 'shipping_category']) + end + end + + context 'updating a product' do + it 'can update a product' do + api_put :update, id: product.to_param, product: { name: 'New and Improved Product!' } + expect(response.status).to eq(200) + end + + it 'can create new option types on a product' do + api_put :update, id: product.to_param, product: { option_types: ['shape', 'color'] } + expect(json_response['option_types'].count).to eq(2) + end + + it 'can create new variants on a product' do + api_put :update, id: product.to_param, product: { variants: [attributes_for_variant, attributes_for_variant.merge(sku: "ABC-#{Kernel.rand(9999)}")] } + expect(response.status).to eq 200 + expect(json_response['variants'].count).to eq(2) # 2 variants + + variants = json_response['variants'].reject { |v| v['is_master'] } + expect(variants.last['option_values'][0]['name']).to eq('small') + expect(variants.last['option_values'][0]['option_type_name']).to eq('size') + + expect(json_response['option_types'].count).to eq(2) # size, color + end + + it 'can update an existing variant on a product' do + variant_hash = { + sku: '123', price: 19.99, options: [{ name: 'size', value: 'small' }] + } + variant_id = product.variants.create!({ product: product }.merge(variant_hash)).id + + api_put :update, id: product.to_param, product: { + variants: [ + variant_hash.merge( + id: variant_id.to_s, + sku: '456', + options: [{ name: 'size', value: 'large' }] + ) + ] + } + + expect(json_response['variants'].count).to eq(1) + variants = json_response['variants'].reject { |v| v['is_master'] } + expect(variants.last['option_values'][0]['name']).to eq('large') + expect(variants.last['sku']).to eq('456') + expect(variants.count).to eq(1) + end + + it 'cannot update a product with an invalid attribute' do + api_put :update, id: product.to_param, product: { name: '' } + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + expect(json_response['errors']['name']).to eq(["can't be blank"]) + end + + it 'puts the updated product in the given taxons' do + api_put :update, id: product.to_param, product: { taxon_ids: [taxon_1.id, taxon_2.id] } + expect(json_response['taxon_ids'].to_set).to eql([taxon_1.id, taxon_2.id].to_set) + end + end + + it 'can delete a product' do + expect(product.deleted_at).to be_nil + api_delete :destroy, id: product.to_param + expect(response.status).to eq(204) + expect(product.reload.deleted_at).not_to be_nil + end + end + + describe '#find_product' do + let(:products) { Spree::Product.all } + + def send_request + api_get :show, id: product.id + end + + before { allow(controller).to receive(:product_scope).and_return(products) } + + context 'product found using friendly_id' do + before do + allow(products).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_return(product) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(controller).to receive(:product_scope).and_return(products) } + it { expect(products).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find).with(product.id.to_s).and_return(product) } + end + + describe 'assigns' do + before { send_request } + + it { expect(assigns(:product)).to eq(product) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to have_http_status(:ok) } + it { expect(json_response[:id]).to eq(product.id) } + it { expect(json_response[:name]).to eq(product.name) } + end + end + + context 'product not found using friendly_id, but found in normal scope using id' do + before do + allow(products).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_raise(ActiveRecord::RecordNotFound) + allow(products).to receive(:find_by).with(id: product.id.to_s).and_return(product) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(controller).to receive(:product_scope).and_return(products) } + it { expect(products).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find_by).with(id: product.id.to_s).and_return(product) } + end + + describe 'assigns' do + before { send_request } + + it { expect(assigns(:product)).to eq(product) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to have_http_status(:ok) } + it { expect(json_response[:id]).to eq(product.id) } + it { expect(json_response[:name]).to eq(product.name) } + end + end + + context 'product not found' do + before do + allow(products).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_raise(ActiveRecord::RecordNotFound) + allow(products).to receive(:find_by).with(id: product.id.to_s).and_return(nil) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(controller).to receive(:product_scope).and_return(products) } + it { expect(products).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find_by).with(id: product.id.to_s).and_return(nil) } + end + + describe 'assigns' do + before { send_request } + + it { expect(assigns(:product)).to eq(nil) } + end + + describe 'response' do + before { send_request } + + it { assert_not_found! } + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/promotion_application_spec.rb b/api/spec/controllers/spree/api/v1/promotion_application_spec.rb new file mode 100644 index 00000000000..fa52be882ea --- /dev/null +++ b/api/spec/controllers/spree/api/v1/promotion_application_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +module Spree + describe Api::V1::OrdersController, type: :controller do + render_views + + before do + stub_authentication! + end + + context 'with an available promotion' do + let!(:order) { create(:order_with_line_items, line_items_count: 1) } + let!(:promotion) do + promotion = Spree::Promotion.create(name: '10% off', code: '10off') + calculator = Spree::Calculator::FlatPercentItemTotal.create(preferred_flat_percent: '10') + action = Spree::Promotion::Actions::CreateItemAdjustments.create(calculator: calculator) + promotion.actions << action + promotion + end + + it 'can apply a coupon code to the order' do + expect(order.total).to eq(110.00) + api_put :apply_coupon_code, id: order.to_param, coupon_code: '10off', order_token: order.token + expect(response.status).to eq(200) + expect(order.reload.total).to eq(109.00) + expect(json_response['success']).to eq('The coupon code was successfully applied to your order.') + expect(json_response['error']).to be_blank + expect(json_response['successful']).to be true + expect(json_response['status_code']).to eq('coupon_code_applied') + end + + context 'with an expired promotion' do + before do + promotion.starts_at = 2.weeks.ago + promotion.expires_at = 1.week.ago + promotion.save + end + + it 'fails to apply' do + api_put :apply_coupon_code, id: order.to_param, coupon_code: '10off', order_token: order.token + expect(response.status).to eq(422) + expect(json_response['success']).to be_blank + expect(json_response['error']).to eq('The coupon code is expired') + expect(json_response['successful']).to be false + expect(json_response['status_code']).to eq('coupon_code_expired') + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/promotions_controller_spec.rb b/api/spec/controllers/spree/api/v1/promotions_controller_spec.rb new file mode 100644 index 00000000000..40200951c8f --- /dev/null +++ b/api/spec/controllers/spree/api/v1/promotions_controller_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +module Spree + describe Api::V1::PromotionsController, type: :controller do + render_views + + shared_examples 'a JSON response' do + it 'is ok' do + expect(subject).to be_ok + end + + it 'returns JSON' do + payload = HashWithIndifferentAccess.new(JSON.parse(subject.body)) + expect(payload).not_to be_nil + Spree::Api::ApiHelpers.promotion_attributes.each do |attribute| + expect(payload.key?(attribute)).to be true + end + end + end + + before do + stub_authentication! + end + + let(:promotion) { create :promotion, :with_order_adjustment, code: '10off' } + + describe 'GET #show' do + subject { api_get :show, id: id } + + context 'when admin' do + sign_in_as_admin! + + context 'when finding by id' do + let(:id) { promotion.id } + + it_behaves_like 'a JSON response' + end + + context 'when finding by code' do + let(:id) { promotion.code } + + it_behaves_like 'a JSON response' + end + + context 'when id does not exist' do + let(:id) { 'argh' } + + it 'is 404' do + expect(subject.status).to eq(404) + end + end + end + + context 'when non admin' do + let(:id) { promotion.id } + + it 'is unauthorized' do + subject + assert_unauthorized! + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/properties_controller_spec.rb b/api/spec/controllers/spree/api/v1/properties_controller_spec.rb new file mode 100644 index 00000000000..e1fe88d3e5d --- /dev/null +++ b/api/spec/controllers/spree/api/v1/properties_controller_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' +module Spree + describe Api::V1::PropertiesController, type: :controller do + render_views + + let!(:property_1) { Property.create!(name: 'foo', presentation: 'Foo') } + let!(:property_2) { Property.create!(name: 'bar', presentation: 'Bar') } + + let(:attributes) { [:id, :name, :presentation] } + + before do + stub_authentication! + end + + it 'can see a list of all properties' do + api_get :index + expect(json_response['properties'].count).to eq(2) + expect(json_response['properties'].first).to have_attributes(attributes) + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['properties'].count).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a parameter' do + api_get :index, q: { name_cont: 'ba' } + expect(json_response['count']).to eq(1) + expect(json_response['properties'].first['presentation']).to eq property_2.presentation + end + + it 'retrieves a list of properties by id' do + api_get :index, ids: [property_1.id] + expect(json_response['properties'].first).to have_attributes(attributes) + expect(json_response['count']).to eq(1) + end + + it 'retrieves a list of properties by ids string' do + api_get :index, ids: [property_1.id, property_2.id].join(',') + expect(json_response['properties'].first).to have_attributes(attributes) + expect(json_response['properties'][1]).to have_attributes(attributes) + expect(json_response['count']).to eq(2) + end + + it 'can see a single property' do + api_get :show, id: property_1.id + expect(json_response).to have_attributes(attributes) + end + + it 'can see a property by name' do + api_get :show, id: property_1.name + expect(json_response).to have_attributes(attributes) + end + + it 'can learn how to create a new property' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + expect(json_response['required_attributes']).to be_empty + end + + it 'cannot create a new property if not an admin' do + api_post :create, property: { name: 'My Property 3' } + assert_unauthorized! + end + + it 'cannot update a property' do + api_put :update, id: property_1.name, property: { presentation: 'my value 456' } + assert_unauthorized! + end + + it 'cannot delete a property' do + api_delete :destroy, id: property_1.id + assert_unauthorized! + expect { property_1.reload }.not_to raise_error + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can create a new property' do + expect(Spree::Property.count).to eq(2) + api_post :create, property: { name: 'My Property 3', presentation: 'my value 3' } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + expect(Spree::Property.count).to eq(3) + end + + it 'can update a property' do + api_put :update, id: property_1.name, property: { presentation: 'my value 456' } + expect(response.status).to eq(200) + end + + it 'can delete a property' do + api_delete :destroy, id: property_1.name + expect(response.status).to eq(204) + expect { property_1.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/reimbursements_controller_spec.rb b/api/spec/controllers/spree/api/v1/reimbursements_controller_spec.rb new file mode 100644 index 00000000000..bfe733364e2 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/reimbursements_controller_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +module Spree + describe Api::V1::ReimbursementsController, type: :controller do + render_views + + before do + stub_authentication! + end + + describe '#index' do + before do + create(:reimbursement) + api_get :index + end + + it 'loads reimbursements' do + expect(response.status).to eq(200) + expect(json_response['count']).to eq(1) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/return_authorizations_controller_spec.rb b/api/spec/controllers/spree/api/v1/return_authorizations_controller_spec.rb new file mode 100644 index 00000000000..08a90e21307 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/return_authorizations_controller_spec.rb @@ -0,0 +1,161 @@ +require 'spec_helper' + +module Spree + describe Api::V1::ReturnAuthorizationsController, type: :controller do + render_views + + let!(:order) { create(:shipped_order) } + + let(:product) { create(:product) } + let(:attributes) { [:id, :memo, :state] } + let(:resource_scoping) { { order_id: order.to_param } } + + before do + stub_authentication! + end + + context 'as the order owner' do + before do + allow_any_instance_of(Order).to receive_messages user: current_api_user + end + + it 'cannot see any return authorizations' do + api_get :index + assert_unauthorized! + end + + it 'cannot see a single return authorization' do + api_get :show, id: 1 + assert_unauthorized! + end + + it 'cannot learn how to create a new return authorization' do + api_get :new + assert_unauthorized! + end + + it 'cannot create a new return authorization' do + api_post :create + assert_unauthorized! + end + + it 'cannot update a return authorization' do + api_put :update, id: 1 + assert_not_found! + end + + it 'cannot delete a return authorization' do + api_delete :destroy, id: 1 + assert_not_found! + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can show return authorization' do + FactoryBot.create(:return_authorization, order: order) + return_authorization = order.return_authorizations.first + api_get :show, order_id: order.number, id: return_authorization.id + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + expect(json_response['state']).not_to be_blank + end + + it 'can get a list of return authorizations' do + FactoryBot.create(:return_authorization, order: order) + FactoryBot.create(:return_authorization, order: order) + api_get :index, order_id: order.number + expect(response.status).to eq(200) + return_authorizations = json_response['return_authorizations'] + expect(return_authorizations.first).to have_attributes(attributes) + expect(return_authorizations.first).not_to eq(return_authorizations.last) + end + + it 'can control the page size through a parameter' do + FactoryBot.create(:return_authorization, order: order) + FactoryBot.create(:return_authorization, order: order) + api_get :index, order_id: order.number, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + FactoryBot.create(:return_authorization, order: order) + expected_result = create(:return_authorization, memo: 'damaged') + order.return_authorizations << expected_result + api_get :index, q: { memo_cont: 'damaged' } + expect(json_response['count']).to eq(1) + expect(json_response['return_authorizations'].first['memo']).to eq expected_result.memo + end + + it 'can learn how to create a new return authorization' do + api_get :new + expect(json_response['attributes']).to eq(['id', 'number', 'state', 'order_id', 'memo', 'created_at', 'updated_at']) + required_attributes = json_response['required_attributes'] + expect(required_attributes).to include('order') + end + + it 'can update a return authorization on the order' do + FactoryBot.create(:return_authorization, order: order) + return_authorization = order.return_authorizations.first + api_put :update, id: return_authorization.id, return_authorization: { memo: 'ABC' } + expect(response.status).to eq(200) + expect(json_response).to have_attributes(attributes) + end + + it 'can cancel a return authorization on the order' do + FactoryBot.create(:new_return_authorization, order: order) + return_authorization = order.return_authorizations.first + expect(return_authorization.state).to eq('authorized') + api_delete :cancel, id: return_authorization.id + expect(response.status).to eq(200) + expect(return_authorization.reload.state).to eq('canceled') + end + + it 'can delete a return authorization on the order' do + FactoryBot.create(:return_authorization, order: order) + return_authorization = order.return_authorizations.first + api_delete :destroy, id: return_authorization.id + expect(response.status).to eq(204) + expect { return_authorization.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'can add a new return authorization to an existing order' do + stock_location = FactoryBot.create(:stock_location) + reason = FactoryBot.create(:return_authorization_reason) + rma_params = { stock_location_id: stock_location.id, + return_authorization_reason_id: reason.id, + memo: 'Defective' } + api_post :create, order_id: order.number, return_authorization: rma_params + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response['state']).not_to be_blank + end + end + + context 'as just another user' do + it 'cannot add a return authorization to the order' do + api_post :create, return_autorization: { order_id: order.number, memo: 'Defective' } + assert_unauthorized! + end + + it 'cannot update a return authorization on the order' do + FactoryBot.create(:return_authorization, order: order) + return_authorization = order.return_authorizations.first + api_put :update, id: return_authorization.id, return_authorization: { memo: 'ABC' } + assert_unauthorized! + expect(return_authorization.reload.memo).not_to eq('ABC') + end + + it 'cannot delete a return authorization on the order' do + FactoryBot.create(:return_authorization, order: order) + return_authorization = order.return_authorizations.first + api_delete :destroy, id: return_authorization.id + assert_unauthorized! + expect { return_authorization.reload }.not_to raise_error + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/shipments_controller_spec.rb b/api/spec/controllers/spree/api/v1/shipments_controller_spec.rb new file mode 100644 index 00000000000..6e0cf2d49fa --- /dev/null +++ b/api/spec/controllers/spree/api/v1/shipments_controller_spec.rb @@ -0,0 +1,229 @@ +require 'spec_helper' + +describe Spree::Api::V1::ShipmentsController, type: :controller do + render_views + let!(:shipment) { create(:shipment) } + let!(:shipment2) { create(:shipment) } + let!(:attributes) { [:id, :tracking, :number, :cost, :shipped_at, :stock_location_name, :order_id, :shipping_rates, :shipping_methods] } + let(:resource_scoping) { { id: shipment.to_param, shipment: { order_id: shipment.order.to_param } } } + + before do + stub_authentication! + end + + context 'as a non-admin' do + it 'cannot make a shipment ready' do + api_put :ready + assert_not_found! + end + + it 'cannot make a shipment shipped' do + api_put :ship + assert_not_found! + end + end + + context 'as an admin' do + let!(:order) { shipment.order } + let!(:stock_location) { create(:stock_location_with_items) } + let!(:variant) { create(:variant) } + + sign_in_as_admin! + + # Start writing this spec a bit differently than before.... + describe 'POST #create' do + subject do + api_post :create, params + end + + let(:params) do + { + variant_id: stock_location.stock_items.first.variant.to_param, + shipment: { order_id: order.number }, + stock_location_id: stock_location.to_param + } + end + + [:variant_id, :stock_location_id].each do |field| + context "when #{field} is missing" do + before do + params.delete(field) + end + + it 'returns proper error' do + subject + expect(response.status).to eq(422) + expect(json_response['exception']).to eq("param is missing or the value is empty: #{field}") + end + end + end + + it 'creates a new shipment' do + expect(subject).to be_ok + expect(json_response).to have_attributes(attributes) + end + end + + describe 'POST #transfer_to_shipment' do + let(:shared_params) do + { + original_shipment_number: shipment.number, + variant_id: stock_location.stock_items.first.variant.to_param, + shipment: { order_id: order.number }, + stock_location_id: stock_location.to_param + } + end + + context 'wrong quantity and shipment target' do + let!(:params) do + shared_params.merge(target_shipment_number: shipment.number, quantity: '-200') + end + + it 'displays wrong target and negative quantity errors' do + api_post :transfer_to_shipment, params + expect(json_response['exception']).to eq("#{Spree.t(:shipment_transfer_errors_occured, scope: 'api')} \n#{Spree.t(:negative_quantity, scope: 'api')}, \n#{Spree.t(:wrong_shipment_target, scope: 'api')}") + end + end + + context 'wrong quantity' do + let!(:params) do + shared_params.merge(target_shipment_number: shipment2.number, quantity: '-200') + end + + it 'displays negative quantity error' do + api_post :transfer_to_shipment, params + expect(json_response['exception']).to eq("#{Spree.t(:shipment_transfer_errors_occured, scope: 'api')} \n#{Spree.t(:negative_quantity, scope: 'api')}") + end + end + + context 'wrong shipment target' do + let!(:params) do + shared_params.merge(target_shipment_number: shipment.number, quantity: '200') + end + + it 'displays wrong target error' do + api_post :transfer_to_shipment, params + expect(json_response['exception']).to eq("#{Spree.t(:shipment_transfer_errors_occured, scope: 'api')} \n#{Spree.t(:wrong_shipment_target, scope: 'api')}") + end + end + end + + context 'should update a shipment' do + let(:resource_scoping) { { id: shipment.to_param, shipment: { order_id: shipment.order.to_param, stock_location_id: stock_location.to_param } } } + + it 'can update a shipment' do + api_put :update + expect(response.status).to eq(200) + expect(json_response['stock_location_name']).to eq(stock_location.name) + end + end + + it 'can make a shipment ready' do + allow_any_instance_of(Spree::Order).to receive_messages(paid?: true, complete?: true) + api_put :ready + expect(json_response).to have_attributes(attributes) + expect(json_response['state']).to eq('ready') + expect(shipment.reload.state).to eq('ready') + end + + it 'cannot make a shipment ready if the order is unpaid' do + allow_any_instance_of(Spree::Order).to receive_messages(paid?: false) + api_put :ready + expect(json_response['error']).to eq('Cannot ready shipment.') + expect(response.status).to eq(422) + end + + context 'for completed shipments' do + let(:order) { create :completed_order_with_totals } + let(:resource_scoping) { { id: order.shipments.first.to_param, shipment: { order_id: order.to_param } } } + + it 'adds a variant to a shipment' do + api_put :add, variant_id: variant.to_param, quantity: 2 + expect(response.status).to eq(200) + expect(json_response['manifest'].detect { |h| h['variant']['id'] == variant.id }['quantity']).to eq(2) + end + + it 'removes a variant from a shipment' do + Spree::Cart::AddItem.call(order: order, variant: variant, quantity: 2) + + api_put :remove, variant_id: variant.to_param, quantity: 1 + expect(response.status).to eq(200) + expect(json_response['manifest'].detect { |h| h['variant']['id'] == variant.id }['quantity']).to eq(1) + end + + it 'removes a destroyed variant from a shipment' do + Spree::Cart::AddItem.call(order: order, variant: variant, quantity: 2) + variant.destroy + + api_put :remove, variant_id: variant.to_param, quantity: 1 + expect(response.status).to eq(200) + expect(json_response['manifest'].detect { |h| h['variant']['id'] == variant.id }['quantity']).to eq(1) + end + end + + context 'can transition a shipment from ready to ship' do + before do + allow_any_instance_of(Spree::Order).to receive_messages(paid?: true, complete?: true) + shipment.update!(shipment.order) + expect(shipment.state).to eq('ready') + allow_any_instance_of(Spree::ShippingRate).to receive_messages(cost: 5) + end + + it 'can transition a shipment from ready to ship' do + shipment.reload + api_put :ship, id: shipment.to_param, shipment: { tracking: '123123', order_id: shipment.order.to_param } + expect(json_response).to have_attributes(attributes) + expect(json_response['state']).to eq('shipped') + end + end + + describe '#mine' do + subject do + api_get :mine, format: 'json', params: params + end + + let(:params) { {} } + + before { subject } + + context 'the current api user is authenticated and has orders' do + let(:current_api_user) { shipped_order.user } + let(:shipped_order) { create(:shipped_order) } + + it 'succeeds' do + expect(response.status).to eq 200 + end + + describe 'json output' do + render_views + + let(:rendered_shipment_ids) { json_response['shipments'].map { |s| s['id'] } } + + it 'contains the shipments' do + expect(rendered_shipment_ids).to match_array current_api_user.orders.flat_map(&:shipments).map(&:id) + end + end + + context 'with filtering' do + let(:params) { { q: { order_completed_at_not_null: 1 } } } + + before do + create(:order, user: current_api_user) # incomplete_order + end + + it 'filters' do + expect(assigns(:shipments).map(&:id)).to match_array current_api_user.orders.complete.flat_map(&:shipments).map(&:id) + end + end + end + + context 'the current api user is not persisted' do + let(:current_api_user) { Spree.user_class.new } + + it 'returns a 401' do + expect(response.status).to eq(401) + end + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/states_controller_spec.rb b/api/spec/controllers/spree/api/v1/states_controller_spec.rb new file mode 100644 index 00000000000..d323c3ba0d8 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/states_controller_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +module Spree + describe Api::V1::StatesController, type: :controller do + render_views + + let!(:state) { create(:state, name: 'Victoria') } + let(:attributes) { [:id, :name, :abbr, :country_id] } + + before do + stub_authentication! + end + + it 'gets all states' do + api_get :index + expect(json_response['states'].first).to have_attributes(attributes) + expect(json_response['states'].first['name']).to eq(state.name) + end + + it 'gets all the states for a particular country' do + api_get :index, country_id: state.country.id + expect(json_response['states'].first).to have_attributes(attributes) + expect(json_response['states'].first['name']).to eq(state.name) + end + + context 'pagination' do + let(:scope) { double('scope') } + + before do + expect(scope).to receive_messages(last: state) + expect(State).to receive_messages(accessible_by: scope) + expect(scope).to receive_messages(order: scope) + allow(scope).to receive_message_chain(:ransack, :result, :includes).and_return(scope) + end + + it 'does not paginate states results when asked not to do so' do + expect(scope).not_to receive(:page) + expect(scope).not_to receive(:per) + api_get :index + end + + it 'paginates when page parameter is passed through' do + expect(scope).to receive(:page).with('1').and_return(scope) + expect(scope).to receive(:per).with(nil).and_return(scope) + api_get :index, page: 1 + end + + it 'paginates when per_page parameter is passed through' do + expect(scope).to receive(:page).with(nil).and_return(scope) + expect(scope).to receive(:per).with('25').and_return(scope) + api_get :index, per_page: 25 + end + end + + context 'with two states' do + before { create(:state, name: 'New South Wales') } + + it 'gets all states for a country' do + country = create(:country, states_required: true) + state.country = country + state.save + + api_get :index, country_id: country.id + expect(json_response['states'].first).to have_attributes(attributes) + expect(json_response['states'].count).to eq(1) + json_response['states_required'] = true + end + + it 'can view all states' do + api_get :index + expect(json_response['states'].first).to have_attributes(attributes) + end + + it 'can query the results through a paramter' do + api_get :index, q: { name_cont: 'Vic' } + expect(json_response['states'].first['name']).to eq('Victoria') + end + end + + it 'can view a state' do + api_get :show, id: state.id + expect(json_response).to have_attributes(attributes) + end + end +end diff --git a/api/spec/controllers/spree/api/v1/stock_items_controller_spec.rb b/api/spec/controllers/spree/api/v1/stock_items_controller_spec.rb new file mode 100644 index 00000000000..458f6218abe --- /dev/null +++ b/api/spec/controllers/spree/api/v1/stock_items_controller_spec.rb @@ -0,0 +1,164 @@ +require 'spec_helper' + +module Spree + describe Api::V1::StockItemsController, type: :controller do + render_views + + let!(:stock_location) { create(:stock_location_with_items) } + let!(:stock_item) { stock_location.stock_items.order(:id).first } + let!(:attributes) { [:id, :count_on_hand, :backorderable, :stock_location_id, :variant_id] } + + before do + stub_authentication! + stock_item.update(backorderable: true) + end + + context 'as a normal user' do + it 'cannot list stock items for a stock location' do + api_get :index, stock_location_id: stock_location.to_param + expect(response.status).to eq(404) + end + + it 'cannot see a stock item' do + api_get :show, stock_location_id: stock_location.to_param, id: stock_item.to_param + expect(response.status).to eq(404) + end + + it 'cannot create a stock item' do + variant = create(:variant) + params = { + stock_location_id: stock_location.to_param, + stock_item: { + variant_id: variant.id, + count_on_hand: '20' + } + } + + api_post :create, params + expect(response.status).to eq(404) + end + + it 'cannot update a stock item' do + api_put :update, stock_location_id: stock_location.to_param, + id: stock_item.to_param + expect(response.status).to eq(404) + end + + it 'cannot destroy a stock item' do + api_delete :destroy, stock_location_id: stock_location.to_param, + id: stock_item.to_param + expect(response.status).to eq(404) + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'cannot list of stock items' do + api_get :index, stock_location_id: stock_location.to_param + expect(json_response['stock_items'].first).to have_attributes(attributes) + expect(json_response['stock_items'].first['variant']['sku']).to include 'SKU' + end + + it 'requires a stock_location_id to be passed as a parameter' do + api_get :index + expect(json_response['error']).to match(/stock_location_id parameter must be provided/) + expect(response.status).to eq(422) + end + + it 'can control the page size through a parameter' do + api_get :index, stock_location_id: stock_location.to_param, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + end + + it 'can query the results through a paramter (count_on_hand)' do + stock_item.update_column(:count_on_hand, 30) + api_get :index, stock_location_id: stock_location.to_param, q: { count_on_hand_eq: '30' } + expect(json_response['count']).to eq(1) + expect(json_response['stock_items'].first['count_on_hand']).to eq 30 + end + + it 'can query the results through a paramter (variant_id)' do + api_get :index, stock_location_id: stock_location.to_param, q: { variant_id_eq: 999_999 } + expect(json_response['count']).to eq(0) + api_get :index, stock_location_id: stock_location.to_param, q: { variant_id_eq: stock_item.variant_id } + expect(json_response['count']).to eq(1) + expect(json_response['stock_items'].first['variant_id']).to eq stock_item.variant_id + end + + it 'gets a stock item' do + api_get :show, stock_location_id: stock_location.to_param, id: stock_item.to_param + expect(json_response).to have_attributes(attributes) + expect(json_response['count_on_hand']).to eq stock_item.count_on_hand + end + + it 'can create a new stock item' do + variant = create(:variant) + # Creating a variant also creates stock items. + # We don't want any to exist (as they would conflict with what we're about to create) + StockItem.delete_all + params = { + stock_location_id: stock_location.to_param, + stock_item: { + variant_id: variant.id, + count_on_hand: '20' + } + } + + api_post :create, params + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + end + + it 'can update a stock item to add new inventory' do + expect(stock_item.count_on_hand).to eq(10) + params = { + id: stock_item.to_param, + stock_item: { + count_on_hand: 40 + } + } + + api_put :update, params + expect(response.status).to eq(200) + expect(json_response['count_on_hand']).to eq 50 + end + + it 'can update a stock item to modify its backorderable field' do + params = { + id: stock_item.to_param, + stock_item: { + backorderable: false + } + } + + api_put :update, params + expect(response.status).to eq(200) + expect(json_response[:backorderable]).to eq(false) + end + + it 'can set a stock item to modify the current inventory' do + expect(stock_item.count_on_hand).to eq(10) + + params = { + id: stock_item.to_param, + stock_item: { + count_on_hand: 40, + force: true + } + } + + api_put :update, params + expect(response.status).to eq(200) + expect(json_response['count_on_hand']).to eq 40 + end + + it 'can delete a stock item' do + api_delete :destroy, id: stock_item.to_param + expect(response.status).to eq(204) + expect { Spree::StockItem.find(stock_item.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/stock_locations_controller_spec.rb b/api/spec/controllers/spree/api/v1/stock_locations_controller_spec.rb new file mode 100644 index 00000000000..09d4a383648 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/stock_locations_controller_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +module Spree + describe Api::V1::StockLocationsController, type: :controller do + render_views + + let!(:stock_location) { create(:stock_location) } + let!(:attributes) { [:id, :name, :address1, :address2, :city, :state_id, :state_name, :country_id, :zipcode, :phone, :active] } + + before do + stub_authentication! + end + + context 'as a user' do + it 'cannot see stock locations' do + api_get :index + expect(response.status).to eq(401) + end + + it 'cannot see a single stock location' do + api_get :show, id: stock_location.id + expect(response.status).to eq(404) + end + + it 'cannot create a new stock location' do + params = { + stock_location: { + name: 'North Pole', + active: true + } + } + + api_post :create, params + expect(response.status).to eq(401) + end + + it 'cannot update a stock location' do + api_put :update, stock_location: { name: 'South Pole' }, id: stock_location.to_param + expect(response.status).to eq(404) + end + + it 'cannot delete a stock location' do + api_put :destroy, id: stock_location.to_param + expect(response.status).to eq(404) + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'gets list of stock locations' do + api_get :index + expect(json_response['stock_locations'].first).to have_attributes(attributes) + expect(json_response['stock_locations'].first['country']).not_to be_nil + expect(json_response['stock_locations'].first['state']).not_to be_nil + end + + it 'can control the page size through a parameter' do + create(:stock_location) + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + expected_result = create(:stock_location, name: 'South America') + api_get :index, q: { name_cont: 'south' } + expect(json_response['count']).to eq(1) + expect(json_response['stock_locations'].first['name']).to eq expected_result.name + end + + it 'gets a stock location' do + api_get :show, id: stock_location.to_param + expect(json_response).to have_attributes(attributes) + expect(json_response['name']).to eq stock_location.name + end + + it 'can create a new stock location' do + params = { + stock_location: { + name: 'North Pole', + active: true + } + } + + api_post :create, params + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + end + + it 'can update a stock location' do + params = { + id: stock_location.to_param, + stock_location: { + name: 'South Pole' + } + } + + api_put :update, params + expect(response.status).to eq(200) + expect(json_response['name']).to eq 'South Pole' + end + + it 'can delete a stock location' do + api_delete :destroy, id: stock_location.to_param + expect(response.status).to eq(204) + expect { stock_location.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/stock_movements_controller_spec.rb b/api/spec/controllers/spree/api/v1/stock_movements_controller_spec.rb new file mode 100644 index 00000000000..cde9de26e26 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/stock_movements_controller_spec.rb @@ -0,0 +1,83 @@ +require 'spec_helper' + +module Spree + describe Api::V1::StockMovementsController, type: :controller do + render_views + + let!(:stock_location) { create(:stock_location_with_items) } + let!(:stock_item) { stock_location.stock_items.order(:id).first } + let!(:stock_movement) { create(:stock_movement, stock_item: stock_item) } + let!(:attributes) { [:id, :quantity, :stock_item_id] } + + before do + stub_authentication! + end + + context 'as a user' do + it 'cannot see a list of stock movements' do + api_get :index, stock_location_id: stock_location.to_param + expect(response.status).to eq(404) + end + + it 'cannot see a stock movement' do + api_get :show, stock_location_id: stock_location.to_param, id: stock_movement.id + expect(response.status).to eq(404) + end + + it 'cannot create a stock movement' do + params = { + stock_location_id: stock_location.to_param, + stock_movement: { + stock_item_id: stock_item.to_param + } + } + + api_post :create, params + expect(response.status).to eq(404) + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'gets list of stock movements' do + api_get :index, stock_location_id: stock_location.to_param + expect(json_response['stock_movements'].first).to have_attributes(attributes) + expect(json_response['stock_movements'].first['stock_item']['count_on_hand']).to eq 11 + end + + it 'can control the page size through a parameter' do + create(:stock_movement, stock_item: stock_item) + api_get :index, stock_location_id: stock_location.to_param, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + create(:stock_movement, :received, quantity: 10, stock_item: stock_item) + api_get :index, stock_location_id: stock_location.to_param, q: { quantity_eq: '10' } + expect(json_response['count']).to eq(1) + end + + it 'gets a stock movement' do + api_get :show, stock_location_id: stock_location.to_param, id: stock_movement.to_param + expect(json_response).to have_attributes(attributes) + expect(json_response['stock_item_id']).to eq stock_movement.stock_item_id + end + + it 'can create a new stock movement' do + params = { + stock_location_id: stock_location.to_param, + stock_movement: { + stock_item_id: stock_item.to_param + } + } + + api_post :create, params + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/stores_controller_spec.rb b/api/spec/controllers/spree/api/v1/stores_controller_spec.rb new file mode 100644 index 00000000000..cf434e12f9c --- /dev/null +++ b/api/spec/controllers/spree/api/v1/stores_controller_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +module Spree + describe Api::V1::StoresController, type: :controller do + render_views + + let!(:store) do + create(:store, name: 'My Spree Store', url: 'spreestore.example.com') + end + + before do + stub_authentication! + end + + context 'as an admin' do + sign_in_as_admin! + + let!(:non_default_store) do + create(:store, + name: 'Extra Store', + url: 'spreestore-5.example.com', + default: false) + end + + it 'I can list the available stores' do + api_get :index + expect(json_response['stores']).to eq( + [ + { + 'id' => store.id, + 'name' => 'My Spree Store', + 'url' => 'spreestore.example.com', + 'meta_description' => nil, + 'meta_keywords' => nil, + 'seo_title' => nil, + 'mail_from_address' => 'spree@example.org', + 'default_currency' => 'USD', + 'code' => store.code, + 'default' => true + }, + { + 'id' => non_default_store.id, + 'name' => 'Extra Store', + 'url' => 'spreestore-5.example.com', + 'meta_description' => nil, + 'meta_keywords' => nil, + 'seo_title' => nil, + 'mail_from_address' => 'spree@example.org', + 'default_currency' => 'USD', + 'code' => non_default_store.code, + 'default' => false + } + ] + ) + end + + it 'I can get the store details' do + api_get :show, id: store.id + expect(json_response).to eq( + 'id' => store.id, + 'name' => 'My Spree Store', + 'url' => 'spreestore.example.com', + 'meta_description' => nil, + 'meta_keywords' => nil, + 'seo_title' => nil, + 'mail_from_address' => 'spree@example.org', + 'default_currency' => 'USD', + 'code' => store.code, + 'default' => true + ) + end + + it 'I can create a new store' do + store_hash = { + code: 'spree123', + name: 'Hack0rz', + url: 'spree123.example.com', + mail_from_address: 'me@example.com' + } + api_post :create, store: store_hash + expect(response.status).to eq(201) + end + + it 'I can update an existing store' do + store_hash = { + url: 'spree123.example.com', + mail_from_address: 'me@example.com' + } + api_put :update, id: store.id, store: store_hash + expect(response.status).to eq(200) + expect(store.reload.url).to eql 'spree123.example.com' + expect(store.reload.mail_from_address).to eql 'me@example.com' + end + + context 'deleting a store' do + it "will fail if it's the default Store" do + api_delete :destroy, id: store.id + expect(response.status).to eq(422) + expect(json_response['errors']['base']).to eql( + ['Cannot destroy the default Store.'] + ) + end + + it 'will destroy the store' do + api_delete :destroy, id: non_default_store.id + expect(response.status).to eq(204) + end + end + end + + context 'as an user' do + it 'I cannot list all the stores' do + api_get :index + expect(response.status).to eq(401) + end + + it 'I cannot get the store details' do + api_get :show, id: store.id + expect(response.status).to eq(401) + end + + it 'I cannot create a new store' do + api_post :create, store: {} + expect(response.status).to eq(401) + end + + it 'I cannot update an existing store' do + api_put :update, id: store.id, store: {} + expect(response.status).to eq(401) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/tags_controller_spec.rb b/api/spec/controllers/spree/api/v1/tags_controller_spec.rb new file mode 100644 index 00000000000..394ecf7debc --- /dev/null +++ b/api/spec/controllers/spree/api/v1/tags_controller_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +module Spree + describe Api::V1::TagsController, type: :controller do + render_views + + let!(:tag) { create(:tag) } + let(:base_attributes) { Api::ApiHelpers.tag_attributes } + + before do + stub_authentication! + end + + context 'as a normal user' do + context 'with caching enabled' do + before do + create(:tag) # tag_2 + ActionController::Base.perform_caching = true + end + + after do + ActionController::Base.perform_caching = false + end + + it 'returns unique tags' do + api_get :index + tag_ids = json_response['tags'].map { |p| p['id'] } + expect(tag_ids.uniq.count).to eq(tag_ids.count) + end + end + + it 'retrieves a list of tags' do + api_get :index + expect(json_response['tags'].first).to have_attributes(base_attributes) + expect(json_response['total_count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + it 'retrieves a list of tags by id' do + api_get :index, ids: [tag.id] + expect(json_response['tags'].first).to have_attributes(base_attributes) + expect(json_response['total_count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + it 'retrieves a list of tags by ids string' do + second_tag = create(:tag) + api_get :index, ids: [tag.id, second_tag.id].join(',') + expect(json_response['tags'].first).to have_attributes(base_attributes) + expect(json_response['tags'][1]).to have_attributes(base_attributes) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + expect(json_response['per_page']).to eq(Kaminari.config.default_per_page) + end + + context 'pagination' do + before { create(:tag) } # second_tag + + it 'can select the next page of tags' do + api_get :index, page: 2, per_page: 1 + expect(json_response['tags'].first).to have_attributes(base_attributes) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(2) + expect(json_response['pages']).to eq(2) + end + + it 'can control the page size through a parameter' do + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + end + + it 'can search for tags' do + create(:tag, name: 'The best tag in the world') + api_get :index, q: { name_cont: 'best' } + expect(json_response['tags'].first).to have_attributes(base_attributes) + expect(json_response['count']).to eq(1) + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can see all tags' do + api_get :index + expect(json_response['tags'].count).to eq(1) + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/taxonomies_controller_spec.rb b/api/spec/controllers/spree/api/v1/taxonomies_controller_spec.rb new file mode 100644 index 00000000000..f08a1801b71 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/taxonomies_controller_spec.rb @@ -0,0 +1,113 @@ +require 'spec_helper' + +module Spree + describe Api::V1::TaxonomiesController, type: :controller do + render_views + + let(:taxonomy) { create(:taxonomy) } + let(:taxon) { create(:taxon, name: 'Ruby', taxonomy: taxonomy) } + let(:taxon2) { create(:taxon, name: 'Rails', taxonomy: taxonomy) } + let(:attributes) { [:id, :name] } + + before do + stub_authentication! + taxon2.children << create(:taxon, name: '3.2.2', taxonomy: taxonomy) + taxon.children << taxon2 + taxonomy.root.children << taxon + end + + context 'as a normal user' do + it 'gets all taxonomies' do + api_get :index + + expect(json_response['taxonomies'].first['name']).to eq taxonomy.name + expect(json_response['taxonomies'].first['root']['taxons'].count).to eq 1 + end + + it 'can control the page size through a parameter' do + create(:taxonomy) + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + expected_result = create(:taxonomy, name: 'Style') + api_get :index, q: { name_cont: 'style' } + expect(json_response['count']).to eq(1) + expect(json_response['taxonomies'].first['name']).to eq expected_result.name + end + + it 'gets a single taxonomy' do + api_get :show, id: taxonomy.id + + expect(json_response['name']).to eq taxonomy.name + + children = json_response['root']['taxons'] + expect(children.count).to eq 1 + expect(children.first['name']).to eq taxon.name + expect(children.first.key?('taxons')).to be false + end + + it 'gets a single taxonomy with set=nested' do + api_get :show, id: taxonomy.id, set: 'nested' + + expect(json_response['name']).to eq taxonomy.name + + children = json_response['root']['taxons'] + expect(children.first.key?('taxons')).to be true + end + + it 'gets the jstree-friendly version of a taxonomy' do + api_get :jstree, id: taxonomy.id + expect(json_response['data']).to eq(taxonomy.root.name) + expect(json_response['attr']).to eq('id' => taxonomy.root.id, 'name' => taxonomy.root.name) + expect(json_response['state']).to eq('closed') + end + + it 'can learn how to create a new taxonomy' do + api_get :new + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + required_attributes = json_response['required_attributes'] + expect(required_attributes).to include('name') + end + + it 'cannot create a new taxonomy if not an admin' do + api_post :create, taxonomy: { name: 'Location' } + assert_unauthorized! + end + + it 'cannot update a taxonomy' do + api_put :update, id: taxonomy.id, taxonomy: { name: 'I hacked your store!' } + assert_unauthorized! + end + + it 'cannot delete a taxonomy' do + api_delete :destroy, id: taxonomy.id + assert_unauthorized! + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can create' do + api_post :create, taxonomy: { name: 'Colors' } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + end + + it 'cannot create a new taxonomy with invalid attributes' do + api_post :create, taxonomy: {} + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + end + + it 'can destroy' do + api_delete :destroy, id: taxonomy.id + expect(response.status).to eq(204) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/taxons_controller_spec.rb b/api/spec/controllers/spree/api/v1/taxons_controller_spec.rb new file mode 100644 index 00000000000..ca2605fc256 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/taxons_controller_spec.rb @@ -0,0 +1,197 @@ +require 'spec_helper' + +def expect_single_taxon_result(taxon_name) + expect(json_response['taxons'].count).to eq(1) + expect(json_response['taxons'].first['name']).to eq(taxon_name) +end + +module Spree + describe Api::V1::TaxonsController, type: :controller do + render_views + + let!(:taxonomy) { create(:taxonomy) } + let!(:taxon) { create(:taxon, name: 'Ruby', taxonomy: taxonomy, parent_id: taxonomy.root.id) } + let!(:rust_taxon) { create(:taxon, name: 'Rust', taxonomy: taxonomy, parent_id: taxonomy.root.id) } + let!(:taxon2) { create(:taxon, name: 'Rails', taxonomy: taxonomy, parent_id: taxon.id) } + let(:attributes) { ['id', 'name', 'pretty_name', 'permalink', 'parent_id', 'taxonomy_id', 'meta_title', 'meta_description'] } + + before do + create(:taxon, name: 'React', taxonomy: taxonomy, parent_id: taxon2.id) # taxon3 + stub_authentication! + end + + context 'as a normal user' do + it 'gets all taxons for a taxonomy' do + api_get :index, taxonomy_id: taxonomy.id + expect(json_response['taxons'].first['name']).to eq taxon.name + children = json_response['taxons'].first['taxons'] + expect(children.count).to eq 1 + expect(children.first['name']).to eq taxon2.name + expect(children.first['taxons'].count).to eq 1 + end + + # Regression test for #4112 + it 'does not include children when asked not to' do + api_get :index, taxonomy_id: taxonomy.id, without_children: 1 + + expect(json_response['taxons'].first['name']).to eq(taxon.name) + expect(json_response['taxons'].first['taxons']).to be_nil + end + + it 'paginates through taxons' do + new_taxon = create(:taxon, name: 'Go', taxonomy: taxonomy, parent_id: taxonomy.root.id) + taxonomy.root.children << new_taxon + expect(taxonomy.root.children.count).to eq(3) + api_get :index, taxonomy_id: taxonomy.id, page: 1, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['total_count']).to eq(3) + expect(json_response['current_page']).to eq(1) + expect(json_response['per_page']).to eq(1) + expect(json_response['pages']).to eq(3) + end + + describe 'searching' do + context 'within a taxonomy' do + before do + api_get :index, taxonomy_id: taxonomy.id, q: { name_cont: name } + end + + context 'searching for top level taxon' do + let(:name) { 'Ruby' } + + it 'returns the matching taxons' do + expect_single_taxon_result 'Ruby' + end + end + end + + context 'with a name' do + before do + api_get :index, q: { name_cont: name } + end + + context 'with one result' do + let(:name) { 'Ruby' } + + it 'returns an array including the matching taxon' do + expect_single_taxon_result 'Ruby' + end + end + + context 'with no results' do + let(:name) { 'Imaginary' } + + it 'returns an empty array of taxons' do + expect(json_response.keys).to include('taxons') + expect(json_response['taxons'].count).to eq(0) + end + end + end + + context 'with no filters' do + it 'gets all taxons' do + api_get :index + + expect(json_response['taxons'].first['name']).to eq taxonomy.root.name + children = json_response['taxons'].first['taxons'] + expect(children.count).to eq 2 + expect(children.first['name']).to eq taxon.name + expect(children.first['taxons'].count).to eq 1 + expect(children.second['name']).to eq rust_taxon.name + expect(children.second['taxons'].count).to eq 0 + end + end + end + + it 'gets a single taxon' do + api_get :show, id: taxon.id, taxonomy_id: taxonomy.id + + expect(json_response['name']).to eq taxon.name + expect(json_response['taxons'].count).to eq 1 + end + + it 'gets all taxons in JSTree form' do + api_get :jstree, taxonomy_id: taxonomy.id, id: taxon.id + response = json_response.first + expect(response['data']).to eq(taxon2.name) + expect(response['attr']).to eq('name' => taxon2.name, 'id' => taxon2.id) + expect(response['state']).to eq('closed') + end + + it 'can learn how to create a new taxon' do + api_get :new, taxonomy_id: taxonomy.id + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + required_attributes = json_response['required_attributes'] + expect(required_attributes).to include('name') + end + + it 'cannot create a new taxon if not an admin' do + api_post :create, taxonomy_id: taxonomy.id, taxon: { name: 'Location' } + assert_unauthorized! + end + + it 'cannot update a taxon' do + api_put :update, taxonomy_id: taxonomy.id, id: taxon.id, taxon: { name: 'I hacked your store!' } + assert_unauthorized! + end + + it 'cannot delete a taxon' do + api_delete :destroy, taxonomy_id: taxonomy.id, id: taxon.id + assert_unauthorized! + end + end + + context 'as an admin' do + sign_in_as_admin! + + it 'can create' do + api_post :create, taxonomy_id: taxonomy.id, taxon: { name: 'Colors' } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + + expect(taxonomy.reload.root.children.count).to eq 3 + taxon = Spree::Taxon.where(name: 'Colors').first + + expect(taxon.parent_id).to eq taxonomy.root.id + expect(taxon.taxonomy_id).to eq taxonomy.id + end + + it 'can update the position in the list' do + taxonomy.root.children << taxon2 + api_put :update, taxonomy_id: taxonomy.id, id: taxon.id, taxon: { parent_id: taxon.parent_id, child_index: 2 } + expect(response.status).to eq(200) + expect(taxonomy.reload.root.children[0]).to eql rust_taxon + expect(taxonomy.reload.root.children[1]).to eql taxon2 + end + + it 'cannot create a new taxon with invalid attributes' do + api_post :create, taxonomy_id: taxonomy.id, taxon: { foo: :bar } + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + expect(taxonomy.reload.root.children.count).to eq 2 + end + + it 'cannot create another root taxon' do + api_post :create, taxonomy_id: taxonomy.id, taxon: { name: 'foo', parent_id: nil } + expect(json_response[:errors][:root_conflict].first).to eq 'this taxonomy already has a root taxon' + end + + it 'cannot create a new taxon with invalid taxonomy_id' do + api_post :create, taxonomy_id: 1000, taxon: { name: 'Colors' } + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + + errors = json_response['errors'] + expect(errors['taxonomy_id']).not_to be_nil + expect(errors['taxonomy_id'].first).to eq 'Invalid taxonomy id.' + + expect(taxonomy.reload.root.children.count).to eq 2 + end + + it 'can destroy' do + api_delete :destroy, taxonomy_id: taxonomy.id, id: taxon.id + expect(response.status).to eq(204) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/unauthenticated_products_controller_spec.rb b/api/spec/controllers/spree/api/v1/unauthenticated_products_controller_spec.rb new file mode 100644 index 00000000000..a4649f46307 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/unauthenticated_products_controller_spec.rb @@ -0,0 +1,25 @@ +require 'shared_examples/protect_product_actions' +require 'spec_helper' + +module Spree + describe Api::V1::ProductsController, type: :controller do + render_views + + let!(:product) { create(:product) } + let(:attributes) { [:id, :name, :description, :price, :available_on, :slug, :meta_description, :meta_keywords, :taxon_ids] } + + context 'without authentication' do + before { Spree::Api::Config[:requires_authentication] = false } + + it 'retrieves a list of products' do + api_get :index + expect(json_response['products'].first).to have_attributes(attributes) + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + + it_behaves_like 'modifying product actions are restricted' + end + end +end diff --git a/api/spec/controllers/spree/api/v1/users_controller_spec.rb b/api/spec/controllers/spree/api/v1/users_controller_spec.rb new file mode 100644 index 00000000000..85e9f48940a --- /dev/null +++ b/api/spec/controllers/spree/api/v1/users_controller_spec.rb @@ -0,0 +1,151 @@ +require 'spec_helper' + +module Spree + describe Api::V1::UsersController, type: :controller do + render_views + + let(:user) { create(:user, spree_api_key: rand.to_s) } + let(:stranger) { create(:user, email: 'stranger@example.com') } + let(:attributes) { [:id, :email, :created_at, :updated_at] } + + context 'as a normal user' do + it 'can get own details' do + api_get :show, id: user.id, token: user.spree_api_key + + expect(json_response['email']).to eq user.email + end + + it 'cannot get other users details' do + api_get :show, id: stranger.id, token: user.spree_api_key + + assert_not_found! + end + + it 'can learn how to create a new user' do + api_get :new, token: user.spree_api_key + expect(json_response['attributes']).to eq(attributes.map(&:to_s)) + end + + it 'can create a new user' do + user_params = { + email: 'new@example.com', password: 'spree123', password_confirmation: 'spree123' + } + + api_post :create, user: user_params, token: user.spree_api_key + expect(json_response['email']).to eq 'new@example.com' + end + + # there's no validations on LegacyUser? + xit 'cannot create a new user with invalid attributes' do + api_post :create, user: {}, token: user.spree_api_key + expect(response.status).to eq(422) + expect(json_response['error']).to eq('Invalid resource. Please fix errors and try again.') + end + + it 'can update own details' do + country = create(:country) + api_put :update, id: user.id, token: user.spree_api_key, user: { + email: 'mine@example.com', + bill_address_attributes: { + first_name: 'First', + last_name: 'Last', + address1: '1 Test Rd', + city: 'City', + country_id: country.id, + state_id: 1, + zipcode: '55555', + phone: '5555555555' + }, + ship_address_attributes: { + first_name: 'First', + last_name: 'Last', + address1: '1 Test Rd', + city: 'City', + country_id: country.id, + state_id: 1, + zipcode: '55555', + phone: '5555555555' + } + } + expect(json_response['email']).to eq 'mine@example.com' + expect(json_response['bill_address']).not_to be_nil + expect(json_response['ship_address']).not_to be_nil + end + + it 'cannot update other users details' do + api_put :update, id: stranger.id, token: user.spree_api_key, user: { email: 'mine@example.com' } + assert_not_found! + end + + it 'can delete itself' do + api_delete :destroy, id: user.id, token: user.spree_api_key + expect(response.status).to eq(204) + end + + it 'cannot delete other user' do + api_delete :destroy, id: stranger.id, token: user.spree_api_key + assert_not_found! + end + + it 'only gets own details on index' do + create_list(:user, 2) + api_get :index, token: user.spree_api_key + + expect(Spree.user_class.count).to eq 3 + expect(json_response['count']).to eq 1 + expect(json_response['users'].size).to eq 1 + end + end + + context 'as an admin' do + before { stub_authentication! } + + sign_in_as_admin! + + it 'gets all users' do + allow(Spree::LegacyUser).to receive(:find_by).with(hash_including(:spree_api_key)) { current_api_user } + + create_list(:user, 2) + + api_get :index + expect(Spree.user_class.count).to eq 2 + expect(json_response['count']).to eq 2 + expect(json_response['users'].size).to eq 2 + end + + it 'can control the page size through a parameter' do + create_list(:user, 2) + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + expected_result = create(:user, email: 'brian@spreecommerce.com') + api_get :index, q: { email_cont: 'brian' } + expect(json_response['count']).to eq(1) + expect(json_response['users'].first['email']).to eq expected_result.email + end + + it 'can create' do + api_post :create, user: { email: 'new@example.com', password: 'spree123', password_confirmation: 'spree123' } + expect(json_response).to have_attributes(attributes) + expect(response.status).to eq(201) + end + + it 'can destroy user without orders' do + user.orders.destroy_all + api_delete :destroy, id: user.id + expect(response.status).to eq(204) + end + + it 'cannot destroy user with orders' do + create(:completed_order_with_totals, user: user) + api_delete :destroy, id: user.id + expect(json_response['exception']).to eq 'Spree::Core::DestroyWithOrdersError' + expect(response.status).to eq(422) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/variants_controller_spec.rb b/api/spec/controllers/spree/api/v1/variants_controller_spec.rb new file mode 100644 index 00000000000..1bbff324861 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/variants_controller_spec.rb @@ -0,0 +1,221 @@ +require 'spec_helper' + +module Spree + describe Api::V1::VariantsController, type: :controller do + render_views + + let(:option_value) { create(:option_value) } + let!(:product) { create(:product) } + let!(:variant) do + variant = product.master + variant.option_values << option_value + variant + end + + let!(:base_attributes) { Api::ApiHelpers.variant_attributes } + let!(:show_attributes) { base_attributes.dup.push(:in_stock, :display_price) } + let!(:new_attributes) { base_attributes } + + before do + stub_authentication! + end + + describe '#variant_includes' do + let(:variants_includes_list) do + [{ option_values: :option_type }, :product, :default_price, :images, { stock_items: :stock_location }] + end + + after { api_get :index } + + it { expect(controller).to receive(:variant_includes).and_return(variants_includes_list) } + end + + it 'adds for_currency_and_available_price_amount scope to variants list' do + expect(Spree::Variant).to receive(:for_currency_and_available_price_amount). + and_return(Spree::Variant.for_currency_and_available_price_amount) + api_get :index + end + + it 'can see a paginated list of variants' do + api_get :index + first_variant = json_response['variants'].first + expect(first_variant).to have_attributes(show_attributes) + expect(first_variant['stock_items']).to be_present + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(1) + end + + it 'can control the page size through a parameter' do + create(:variant) + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a parameter' do + expected_result = create(:variant, sku: 'FOOBAR') + api_get :index, q: { sku_cont: 'FOO' } + expect(json_response['count']).to eq(1) + expect(json_response['variants'].first['sku']).to eq expected_result.sku + end + + it 'variants returned contain option values data' do + api_get :index + option_values = json_response['variants'].last['option_values'] + expect(option_values.first).to have_attributes([:name, + :presentation, + :option_type_name, + :option_type_id]) + end + + it 'variants returned contain images data' do + create_image(variant, image('thinking-cat.jpg')) + + api_get :index + + expect(json_response['variants'].last).to have_attributes([:images]) + expect(json_response['variants'].first['images'].first).to have_attributes([:attachment_file_name, + :attachment_width, + :attachment_height, + :attachment_content_type, + :mini_url, + :small_url, + :product_url, + :large_url]) + end + + it 'variants returned do not contain cost price data' do + api_get :index + expect(json_response['variants'].first.key?(:cost_price)).to eq false + end + + # Regression test for #2141 + context 'a deleted variant' do + before do + variant.update_column(:deleted_at, Time.current) + end + + it 'is not returned in the results' do + api_get :index + expect(json_response['variants'].count).to eq(0) + end + + it 'is not returned even when show_deleted is passed' do + api_get :index, show_deleted: true + expect(json_response['variants'].count).to eq(0) + end + end + + context 'pagination' do + before { create(:variant) } + + it 'can select the next page of variants' do + api_get :index, page: 2, per_page: 1 + expect(json_response['variants'].first).to have_attributes(show_attributes) + expect(json_response['total_count']).to eq(2) + expect(json_response['current_page']).to eq(2) + expect(json_response['pages']).to eq(2) + end + end + + it 'can see a single variant' do + api_get :show, id: variant.to_param + expect(json_response).to have_attributes(show_attributes) + expect(json_response['stock_items']).to be_present + option_values = json_response['option_values'] + expect(option_values.first).to have_attributes([:name, + :presentation, + :option_type_name, + :option_type_id]) + end + + it 'can see a single variant with images' do + create_image(variant, image('thinking-cat.jpg')) + + api_get :show, id: variant.to_param + + expect(json_response).to have_attributes(show_attributes + [:images]) + option_values = json_response['option_values'] + expect(option_values.first).to have_attributes([:name, + :presentation, + :option_type_name, + :option_type_id]) + end + + it 'can learn how to create a new variant' do + api_get :new + expect(json_response['attributes']).to eq(new_attributes.map(&:to_s)) + expect(json_response['required_attributes']).to be_empty + end + + it 'cannot create a new variant if not an admin' do + api_post :create, variant: { sku: '12345' } + assert_unauthorized! + end + + it 'cannot update a variant' do + api_put :update, id: variant.to_param, variant: { sku: '12345' } + assert_not_found! + end + + it 'cannot delete a variant' do + api_delete :destroy, id: variant.to_param + assert_not_found! + expect { variant.reload }.not_to raise_error + end + + context 'as an admin' do + sign_in_as_admin! + let(:resource_scoping) { { product_id: variant.product.to_param } } + + # Test for #2141 + context 'deleted variants' do + before do + variant.update_columns(deleted_at: Time.current, discontinue_on: Time.current + 1) + end + + it 'are visible by admin' do + api_get :index, show_deleted: 1 + expect(json_response['variants'].count).to eq(1) + end + end + + it 'can create a new variant' do + other_value = create(:option_value) + api_post :create, variant: { + sku: '12345', + price: '20', + option_value_ids: [option_value.id, other_value.id] + } + + expect(json_response).to have_attributes(new_attributes) + expect(response.status).to eq(201) + expect(json_response['sku']).to eq('12345') + expect(json_response['price']).to match '20' + + option_value_ids = json_response['option_values'].map { |o| o['id'] } + expect(option_value_ids).to match_array [option_value.id, other_value.id] + + expect(variant.product.variants.count).to eq(1) + end + + it 'can update a variant' do + api_put :update, id: variant.to_param, variant: { sku: '12345' } + expect(response.status).to eq(200) + end + + it 'can delete a variant' do + api_delete :destroy, id: variant.to_param + expect(response.status).to eq(204) + expect { Spree::Variant.find(variant.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'variants returned contain cost price data' do + api_get :index + expect(json_response['variants'].first.key?(:cost_price)).to eq true + end + end + end +end diff --git a/api/spec/controllers/spree/api/v1/zones_controller_spec.rb b/api/spec/controllers/spree/api/v1/zones_controller_spec.rb new file mode 100644 index 00000000000..0ece66c1962 --- /dev/null +++ b/api/spec/controllers/spree/api/v1/zones_controller_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +module Spree + describe Api::V1::ZonesController, type: :controller do + render_views + + let!(:attributes) { [:id, :name, :zone_members] } + + before do + stub_authentication! + @zone = create(:zone, name: 'Europe') + end + + it 'gets list of zones' do + api_get :index + expect(json_response['zones'].first).to have_attributes(attributes) + end + + it 'can control the page size through a parameter' do + create(:zone) + api_get :index, per_page: 1 + expect(json_response['count']).to eq(1) + expect(json_response['current_page']).to eq(1) + expect(json_response['pages']).to eq(2) + end + + it 'can query the results through a paramter' do + expected_result = create(:zone, name: 'South America') + api_get :index, q: { name_cont: 'south' } + expect(json_response['count']).to eq(1) + expect(json_response['zones'].first['name']).to eq expected_result.name + end + + it 'gets a zone' do + api_get :show, id: @zone.id + expect(json_response).to have_attributes(attributes) + expect(json_response['name']).to eq @zone.name + expect(json_response['zone_members'].size).to eq @zone.zone_members.count + end + + context 'as an admin' do + sign_in_as_admin! + + let!(:country) { create(:country) } + + it 'can create a new zone' do + params = { + zone: { + name: 'North Pole', + zone_members: [ + { + zoneable_type: 'Spree::Country', + zoneable_id: country.id + } + ] + } + } + + api_post :create, params + expect(response.status).to eq(201) + expect(json_response).to have_attributes(attributes) + expect(json_response['zone_members']).not_to be_empty + end + + it 'updates a zone' do + params = { id: @zone.id, + zone: { + name: 'North Pole', + zone_members: [ + { + zoneable_type: 'Spree::Country', + zoneable_id: country.id + } + ] + } } + + api_put :update, params + expect(response.status).to eq(200) + expect(json_response['name']).to eq 'North Pole' + expect(json_response['zone_members']).not_to be_blank + end + + it 'can delete a zone' do + api_delete :destroy, id: @zone.id + expect(response.status).to eq(204) + expect { @zone.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/api/spec/controllers/spree/api/v2/base_controller_spec.rb b/api/spec/controllers/spree/api/v2/base_controller_spec.rb new file mode 100644 index 00000000000..f2f8853c7d7 --- /dev/null +++ b/api/spec/controllers/spree/api/v2/base_controller_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +class DummyController < Spree::Api::V2::BaseController + private + + def default_resource_includes + %w[variants images] + end +end + +describe Spree::Api::V2::BaseController, type: :controller do + let(:dummy_controller) { DummyController.new } + + describe '#default_resource_includes' do + context 'not implemented' do + it 'returns an empty array' do + expect(controller.send(:default_resource_includes)).to eq([]) + end + end + + context 'implemented' do + it 'overrides the method' do + expect(dummy_controller.send(:default_resource_includes)).to eq(%w[variants images]) + end + end + end + + describe '#resource_includes' do + context 'passed as params' do + before do + dummy_controller.params = { include: 'variants,images,taxons' } + end + + it 'returns included resources specified in params' do + expect(dummy_controller.send(:resource_includes)).to eq([:variants, :images, :taxons]) + end + end + + context 'not passed in params' do + before do + dummy_controller.params = {} + end + + it 'returns default resources' do + expect(dummy_controller.send(:resource_includes)).to eq([:variants, :images]) + end + end + end + + describe '#sparse_fields' do + shared_examples 'invalid params format' do + it 'returns nil' do + expect(dummy_controller.send(:sparse_fields)).to eq(nil) + end + end + + context 'not passed in params' do + before do + dummy_controller.params = {} + end + + it_behaves_like 'invalid params format' + end + + context 'with no field type specified' do + before do + dummy_controller.params = { fields: 'name,slug,price' } + end + + it_behaves_like 'invalid params format' + end + + context 'with type values not comma separated' do + before do + dummy_controller.params = { fields: { product: { values: 'name,slug,price' } } } + end + + it_behaves_like 'invalid params format' + end + + context 'with valid params format' do + before do + dummy_controller.params = { fields: { product: 'name,slug,price' } } + end + + it 'returns specified params' do + expect(dummy_controller.send(:sparse_fields)).to eq(product: [:name, :slug, :price]) + end + end + end +end diff --git a/api/spec/fixtures/thinking-cat.jpg b/api/spec/fixtures/thinking-cat.jpg new file mode 100644 index 00000000000..7e8524d367b Binary files /dev/null and b/api/spec/fixtures/thinking-cat.jpg differ diff --git a/api/spec/models/spree/api_dependencies_spec.rb b/api/spec/models/spree/api_dependencies_spec.rb new file mode 100644 index 00000000000..0a9918756a3 --- /dev/null +++ b/api/spec/models/spree/api_dependencies_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +class MyNewSerializer + include FastJsonapi::ObjectSerializer + + attributes :total +end + +class MyCustomCreateService +end + +describe Spree::ApiDependencies, type: :model do + let (:deps) { Spree::ApiDependencies.new } + + it 'returns the default value' do + expect(deps.storefront_cart_serializer).to eq('Spree::V2::Storefront::CartSerializer') + end + + it 'allows to overwrite the value' do + deps.storefront_cart_serializer = MyNewSerializer + expect(deps.storefront_cart_serializer).to eq MyNewSerializer + end + + it 'respects global dependecies' do + Spree::Dependencies.cart_create_service = MyCustomCreateService + expect(deps.storefront_cart_create_service).to eq(MyCustomCreateService) + end +end diff --git a/api/spec/models/spree/legacy_user_spec.rb b/api/spec/models/spree/legacy_user_spec.rb new file mode 100644 index 00000000000..90387189462 --- /dev/null +++ b/api/spec/models/spree/legacy_user_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +module Spree + describe LegacyUser, type: :model do + let(:user) { LegacyUser.new } + + it 'can generate an API key' do + expect(user).to receive(:save!) + user.generate_spree_api_key! + expect(user.spree_api_key).not_to be_blank + end + + it 'can clear an API key' do + expect(user).to receive(:save!) + user.clear_spree_api_key! + expect(user.spree_api_key).to be_blank + end + end +end diff --git a/api/spec/requests/rabl_cache_spec.rb b/api/spec/requests/rabl_cache_spec.rb new file mode 100644 index 00000000000..db240158a49 --- /dev/null +++ b/api/spec/requests/rabl_cache_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe 'Rabl Cache', type: :request, caching: true do + let!(:user) { create(:admin_user) } + + before do + create(:variant) + user.generate_spree_api_key! + expect(Spree::Product.count).to eq(1) + end + + it "doesn't create a cache key collision for models with different rabl templates" do + get '/api/v1/variants', params: { token: user.spree_api_key } + expect(response.status).to eq(200) + + # Make sure we get a non master variant + variant_a = JSON.parse(response.body)['variants'].reject do |v| + v['is_master'] + end.first + + expect(variant_a['is_master']).to be false + expect(variant_a['stock_items']).not_to be_nil + + get "/api/v1/products/#{Spree::Product.first.id}", params: { token: user.spree_api_key } + expect(response.status).to eq(200) + variant_b = JSON.parse(response.body)['variants'].last + expect(variant_b['is_master']).to be false + + expect(variant_a['id']).to eq(variant_b['id']) + expect(variant_b['stock_items']).to be_nil + end +end diff --git a/api/spec/requests/ransackable_attributes_spec.rb b/api/spec/requests/ransackable_attributes_spec.rb new file mode 100644 index 00000000000..d84e95c9038 --- /dev/null +++ b/api/spec/requests/ransackable_attributes_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +describe 'Ransackable Attributes' do + let(:user) { create(:user).tap(&:generate_spree_api_key!) } + let(:order) { create(:order_with_line_items, user: user) } + + context 'filtering by attributes one association away' do + it 'does not allow the filtering of variants by order attributes' do + create_list(:variant, 2) + + get "/api/v1/variants?q[orders_email_start]=#{order.email}", params: { token: user.spree_api_key } + + variants_response = JSON.parse(response.body) + expect(variants_response['total_count']).to eq(Spree::Variant.eligible.count) + end + end + + context 'filtering by attributes two associations away' do + it 'does not allow the filtering of variants by user attributes' do + create_list(:variant, 2) + + get "/api/v1/variants?q[orders_user_email_start]=#{order.user.email}", params: { token: user.spree_api_key } + + variants_response = JSON.parse(response.body) + expect(variants_response['total_count']).to eq(Spree::Variant.eligible.count) + end + end + + context 'it maintains desired association behavior' do + it 'allows filtering of variants product name' do + product = create(:product, name: 'Fritos') + variant = create(:variant, product: product) + other_variant = create(:variant) + + get '/api/v1/variants?q[product_name_or_sku_cont]=fritos', params: { token: user.spree_api_key } + + skus = JSON.parse(response.body)['variants'].map { |var| var['sku'] } + expect(skus).to include variant.sku + expect(skus).not_to include other_variant.sku + end + end + + context 'filtering by attributes' do + it 'most attributes are not filterable by default' do + create(:product, meta_title: 'special product') + create(:product) + + get '/api/v1/products?q[meta_title_cont]=special', params: { token: user.spree_api_key } + + products_response = JSON.parse(response.body) + expect(products_response['total_count']).to eq(Spree::Product.count) + end + + it 'id is filterable by default' do + product = create(:product) + other_product = create(:product) + + get "/api/v1/products?q[id_eq]=#{product.id}", params: { token: user.spree_api_key } + + product_names = JSON.parse(response.body)['products'].map { |prod| prod['name'] } + expect(product_names).to include product.name + expect(product_names).not_to include other_product.name + end + end + + context 'filtering by whitelisted attributes' do + it 'filtering is supported for whitelisted attributes' do + product = create(:product, name: 'Fritos') + other_product = create(:product) + + get '/api/v1/products?q[name_cont]=fritos', params: { token: user.spree_api_key } + + product_names = JSON.parse(response.body)['products'].map { |prod| prod['name'] } + expect(product_names).to include product.name + expect(product_names).not_to include other_product.name + end + end +end diff --git a/api/spec/requests/spree/api/errors_spec.rb b/api/spec/requests/spree/api/errors_spec.rb new file mode 100644 index 00000000000..b1894855c04 --- /dev/null +++ b/api/spec/requests/spree/api/errors_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe 'API Errors Spec', type: :request do + context 'unexisting API route' do + it 'returns 404' do + get '/api/prods' + + expect(response).to redirect_to('/api/v1/prods.json') + follow_redirect! + + expect(response).to redirect_to('/api/404') + follow_redirect! + + expect(response.status).to eq 404 + end + end +end diff --git a/api/spec/requests/spree/api/v2/errors_spec.rb b/api/spec/requests/spree/api/v2/errors_spec.rb new file mode 100644 index 00000000000..e7b2f9fabf3 --- /dev/null +++ b/api/spec/requests/spree/api/v2/errors_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'API v2 Errors spec', type: :request do + context 'record not found' do + before { get '/api/v2/storefront/products/product-that-doesn-t-exist' } + + it_behaves_like 'returns 404 HTTP status' + + it 'returns proper error message' do + expect(json_response['error']).to eq('The resource you were looking for could not be found.') + end + end + + context 'authorization failure' do + let(:user) { create(:user) } + let(:another_user) { create(:user) } + let!(:order) { create(:order, user: another_user) } + + include_context 'API v2 tokens' + + before do + allow_any_instance_of(Spree::Api::V2::Storefront::CartController).to receive(:spree_current_order).and_return(order) + patch '/api/v2/storefront/cart/empty', headers: headers_bearer + end + + it_behaves_like 'returns 403 HTTP status' + + it 'returns proper error message' do + expect(json_response['error']).to eq('You are not authorized to access this page.') + end + end +end diff --git a/api/spec/requests/spree/api/v2/resource_includes_spec.rb b/api/spec/requests/spree/api/v2/resource_includes_spec.rb new file mode 100644 index 00000000000..96350db0277 --- /dev/null +++ b/api/spec/requests/spree/api/v2/resource_includes_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'API v2 JSON API Resource Includes Spec', type: :request do + let!(:products) { create_list(:product, 5) } + let!(:product) { create(:product) } + let!(:product_option_type) { create(:product_option_type, product: product) } + let!(:option_value) { create(:option_value, option_type: product_option_type.option_type) } + let(:default_variant) { product.master } + + shared_examples 'requested resources' do + it 'are returned' do + expect(json_response['included']).to be_present + expect(json_response['included']).to include(have_type('variant').and have_id(default_variant.id.to_s)) + expect(json_response['included']).not_to include(have_type('option_type')) + end + end + + shared_examples 'nested requested resources' do + it 'are returned' do + expect(json_response['included']).to be_present + expect(json_response['included']).not_to include(have_type('variant').and have_id(default_variant.id.to_s)) + expect(json_response['included']).to include(have_type('option_type')) + expect(json_response['included']).to include(have_type('option_value')) + end + end + + shared_examples 'requested no resources' do + it 'nothing is returned' do + expect(json_response['included']).not_to be_present + end + end + + context 'singular resource' do + context 'without include param' do + before { get "/api/v2/storefront/products/#{product.id}" } + + it_behaves_like 'requested no resources' + end + + context 'with include param' do + context 'empty param' do + before { get "/api/v2/storefront/products/#{product.id}?include=" } + + it_behaves_like 'requested no resources' + end + + context 'with non-existing relation requested' do + before { get "/api/v2/storefront/products/#{product.id}?include=does_not_exist" } + + it_behaves_like 'returns 400 HTTP status' + end + + context 'present param' do + context 'without nested resources' do + before { get "/api/v2/storefront/products/#{product.id}?include=default_variant" } + + it_behaves_like 'requested resources' + end + + context 'with nested resources' do + before { get "/api/v2/storefront/products/#{product.id}?include=option_types,option_types.option_values" } + + it_behaves_like 'nested requested resources' + end + end + end + end + + context 'collections' do + context 'without include param' do + before { get '/api/v2/storefront/products' } + + it_behaves_like 'requested no resources' + end + + context 'with include param' do + context 'empty param' do + before { get '/api/v2/storefront/products?include=' } + + it_behaves_like 'requested no resources' + end + + context 'present param' do + context 'without nested resources' do + before { get '/api/v2/storefront/products?include=default_variant' } + + it_behaves_like 'requested resources' + end + + context 'with nested resources' do + before { get '/api/v2/storefront/products?include=option_types,option_types.option_values' } + + it_behaves_like 'nested requested resources' + end + end + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/account/credit_cards_spec.rb b/api/spec/requests/spree/api/v2/storefront/account/credit_cards_spec.rb new file mode 100644 index 00000000000..39233eb8f44 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/account/credit_cards_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'Storefront API v2 CreditCards spec', type: :request do + let!(:user) { create(:user) } + let!(:params) { { user_id: user.id } } + let!(:credit_cards) { create_list(:credit_card, 3, user_id: user.id) } + + shared_examples 'returns valid user credit cards resource JSON' do + it 'returns a valid user credit cards resource JSON response' do + expect(response.status).to eq(200) + + expect(json_response['data'][0]).to have_type('credit_card') + expect(json_response['data'][0]).to have_relationships(:payment_method) + expect(json_response['data'][0]).to have_attribute(:last_digits) + expect(json_response['data'][0]).to have_attribute(:month) + expect(json_response['data'][0]).to have_attribute(:year) + expect(json_response['data'][0]).to have_attribute(:name) + end + end + + include_context 'API v2 tokens' + + describe 'credit_cards#index' do + context 'with filter options' do + before { get "/api/v2/storefront/account/credit_cards?filter[payment_method_id]=#{credit_cards.first.payment_method_id}&include=payment_method", headers: headers_bearer } + + it_behaves_like 'returns valid user credit cards resource JSON' + + it 'returns all user credit_cards' do + expect(json_response['data'].size).to eq(1) + end + end + + context 'without options' do + before { get '/api/v2/storefront/account/credit_cards', headers: headers_bearer } + + it_behaves_like 'returns valid user credit cards resource JSON' + + it 'returns all user credit_cards' do + expect(json_response['data'][0]).to have_type('credit_card') + expect(json_response['data'].size).to eq(credit_cards.count) + end + end + + context 'with missing authorization token' do + before { get '/api/v2/storefront/account/credit_cards' } + + it_behaves_like 'returns 403 HTTP status' + end + end + + describe 'credit_cards#show' do + context 'by "default"' do + let!(:default_credit_card) { create(:credit_card, user_id: user.id, default: true) } + + before { get '/api/v2/storefront/account/credit_cards/default', headers: headers_bearer } + + it 'returns user default credit_card' do + expect(json_response['data']).to have_id(default_credit_card.id.to_s) + expect(json_response['data']).to have_attribute(:cc_type).with_value(default_credit_card.cc_type) + expect(json_response['data']).to have_attribute(:last_digits).with_value(default_credit_card.last_digits) + expect(json_response['data']).to have_attribute(:name).with_value(default_credit_card.name) + expect(json_response['data']).to have_attribute(:month).with_value(default_credit_card.month) + expect(json_response['data']).to have_attribute(:year).with_value(default_credit_card.year) + expect(json_response.size).to eq(1) + end + end + + context 'with missing authorization token' do + before { get '/api/v2/storefront/account/credit_cards/default' } + + it_behaves_like 'returns 403 HTTP status' + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/account/orders_spec.rb b/api/spec/requests/spree/api/v2/storefront/account/orders_spec.rb new file mode 100644 index 00000000000..eef403cd4a1 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/account/orders_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' +require 'shared_examples/api_v2/current_order' + +describe 'Storefront API v2 Orders spec', type: :request do + let!(:user) { create(:user_with_addresses) } + let!(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current) } + + include_context 'API v2 tokens' + + describe 'orders#index' do + context 'with option: include' do + before { get '/api/v2/storefront/account/orders?include=billing_address', headers: headers_bearer } + + it 'returns orders' do + expect(json_response['data'].size).to eq 1 + expect(json_response['data']).to be_kind_of(Array) + + expect(json_response['data'][0]).to be_present + expect(json_response['data'][0]).to have_id(order.id.to_s) + expect(json_response['data'][0]).to have_type('cart') + expect(json_response['data'][0]).to have_attribute(:number).with_value(order.number) + expect(json_response['data'][0]).to have_attribute(:state).with_value(order.state) + expect(json_response['data'][0]).to have_attribute(:token).with_value(order.token) + expect(json_response['data'][0]).to have_attribute(:total).with_value(order.total.to_s) + expect(json_response['data'][0]).to have_attribute(:item_total).with_value(order.item_total.to_s) + expect(json_response['data'][0]).to have_attribute(:ship_total).with_value(order.ship_total.to_s) + expect(json_response['data'][0]).to have_attribute(:adjustment_total).with_value(order.adjustment_total.to_s) + expect(json_response['data'][0]).to have_attribute(:included_tax_total).with_value(order.included_tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:additional_tax_total).with_value(order.additional_tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_additional_tax_total).with_value(order.display_additional_tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_included_tax_total).with_value(order.display_included_tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:tax_total).with_value(order.tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:currency).with_value(order.currency.to_s) + expect(json_response['data'][0]).to have_attribute(:email).with_value(order.email) + expect(json_response['data'][0]).to have_attribute(:display_item_total).with_value(order.display_item_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_ship_total).with_value(order.display_ship_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_adjustment_total).with_value(order.display_adjustment_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_tax_total).with_value(order.display_tax_total.to_s) + expect(json_response['data'][0]).to have_attribute(:item_count).with_value(order.item_count) + expect(json_response['data'][0]).to have_attribute(:special_instructions).with_value(order.special_instructions) + expect(json_response['data'][0]).to have_attribute(:promo_total).with_value(order.promo_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_promo_total).with_value(order.display_promo_total.to_s) + expect(json_response['data'][0]).to have_attribute(:display_total).with_value(order.display_total.to_s) + expect(json_response['data'][0]).to have_relationships(:user, :line_items, :variants, :billing_address, :shipping_address, :payments, :shipments, :promotions) + end + + it 'returns included resource' do + expect(json_response['included'].size).to eq Spree::Order.count + expect(json_response['included'][0]).to have_type('address') + end + end + + context 'with specified pagination params' do + let!(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current) } + let!(:order_1) { create(:order, state: 'complete', user: user, completed_at: Time.current + 1.day) } + let!(:order_2) { create(:order, state: 'complete', user: user, completed_at: Time.current + 2.days) } + let!(:order_3) { create(:order, state: 'complete', user: user, completed_at: Time.current + 3.days) } + + before { get '/api/v2/storefront/account/orders?page=1&per_page=2', headers: headers_bearer } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount orders' do + expect(json_response['data'].count).to eq 2 + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq 2 + expect(json_response['meta']['total_count']).to eq Spree::Order.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/account/orders?page=1&per_page=2') + expect(json_response['links']['next']).to include('/api/v2/storefront/account/orders?page=2&per_page=2') + expect(json_response['links']['prev']).to include('/api/v2/storefront/account/orders?page=1&per_page=2') + end + end + + context 'without specified pagination params' do + before { get '/api/v2/storefront/account/orders', headers: headers_bearer } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount orders' do + expect(json_response['data'].count).to eq Spree::Order.count + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq Spree::Order.count + expect(json_response['meta']['total_count']).to eq Spree::Order.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/account/orders') + expect(json_response['links']['next']).to include('/api/v2/storefront/account/orders?page=1') + expect(json_response['links']['prev']).to include('/api/v2/storefront/account/orders?page=1') + end + end + + + context 'sort orders' do + let!(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current) } + let!(:order_1) { create(:order, state: 'complete', user: user, completed_at: Time.current + 1.day) } + let!(:order_2) { create(:order, state: 'complete', user: user, completed_at: Time.current + 2.days) } + let!(:order_3) { create(:order, state: 'complete', user: user, completed_at: Time.current + 3.days) } + + context 'sorting by completed_at' do + context 'ascending order' do + before { get '/api/v2/storefront/account/orders?sort=completed_at', headers: headers_bearer } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns orders sorted by completed_at' do + expect(json_response['data'].count).to eq Spree::Order.count + expect(json_response['data'].pluck(:id)).to eq Spree::Order.select('*').order(completed_at: :desc).pluck(:id).map(&:to_s) + end + end + + context 'descending order' do + before { get '/api/v2/storefront/account/orders?sort=-completed_at', headers: headers_bearer } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns orders sorted by completed_at' do + expect(json_response['data'].count).to eq Spree::Order.count + expect(json_response['data'].pluck(:id)).to eq Spree::Order.select('*').order(completed_at: :asc).pluck(:id).map(&:to_s) + end + end + end + end + + context 'with missing authorization token' do + before { get '/api/v2/storefront/account/orders' } + + it_behaves_like 'returns 403 HTTP status' + end + end + + describe 'orders#show' do + let(:user) { create(:user_with_addresses) } + let(:order) { create(:order, state: 'complete', user: user, completed_at: Time.current) } + + context 'without option: include' do + before { get "/api/v2/storefront/account/orders/#{order.number}", headers: headers_bearer } + + it_behaves_like 'returns valid cart JSON' + end + + context 'with option: include' do + before { get "/api/v2/storefront/account/orders/#{order.number}?include=billing_address", headers: headers_bearer } + + it_behaves_like 'returns valid cart JSON' + + it 'return hash with included' do + expect(json_response['included']).to be_present + expect(json_response['included'][0]).to have_type('address') + end + end + + context 'with missing order number' do + before { get '/api/v2/storefront/account/orders/23212', headers: headers_bearer } + + it_behaves_like 'returns 404 HTTP status' + end + + context 'with missing authorization token' do + before { get '/api/v2/storefront/account/orders' } + + it_behaves_like 'returns 403 HTTP status' + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/account_spec.rb b/api/spec/requests/spree/api/v2/storefront/account_spec.rb new file mode 100644 index 00000000000..153e03066fc --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/account_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'Storefront API v2 Account spec', type: :request do + include_context 'API v2 tokens' + + let!(:user) { create(:user_with_addresses) } + let(:headers) { headers_bearer } + + describe 'account#show' do + before { get '/api/v2/storefront/account', headers: headers } + + it_behaves_like 'returns 200 HTTP status' + + it 'return JSON API payload of User and associations (default billing and shipping address)' do + expect(json_response['data']).to have_id(user.id.to_s) + expect(json_response['data']).to have_type('user') + expect(json_response['data']).to have_relationships(:default_shipping_address, :default_billing_address) + + expect(json_response['data']).to have_attribute(:email).with_value(user.email) + expect(json_response['data']).to have_attribute(:store_credits).with_value(user.total_available_store_credit) + expect(json_response['data']).to have_attribute(:completed_orders).with_value(user.orders.complete.count) + end + + context 'with params "include=default_billing_address"' do + before { get '/api/v2/storefront/account?include=default_billing_address', headers: headers } + + it 'returns account data with included default billing address' do + expect(json_response['included']).to include(have_type('address')) + expect(json_response['included'][0]).to eq(Spree::V2::Storefront::AddressSerializer.new(user.billing_address).as_json['data']) + end + end + + context 'with params "include=default_shipping_address"' do + before { get '/api/v2/storefront/account?include=default_shipping_address', headers: headers } + + it 'returns account data with included default shipping address' do + expect(json_response['included']).to include(have_type('address')) + expect(json_response['included'][0]).to eq(Spree::V2::Storefront::AddressSerializer.new(user.shipping_address).as_json['data']) + end + end + + context 'with params include=default_billing_address,default_shipping_address' do + before { get '/api/v2/storefront/account?include=default_billing_address,default_shipping_address', headers: headers } + + it 'returns account data with included default billing and shipping addresses' do + expect(json_response['included']).to include(have_type('address')) + expect(json_response['included'][0]).to eq(Spree::V2::Storefront::AddressSerializer.new(user.billing_address).as_json['data']) + expect(json_response['included'][1]).to eq(Spree::V2::Storefront::AddressSerializer.new(user.shipping_address).as_json['data']) + end + end + + context 'as a guest user' do + let(:headers) { {} } + + it_behaves_like 'returns 403 HTTP status' + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/cart_spec.rb b/api/spec/requests/spree/api/v2/storefront/cart_spec.rb new file mode 100644 index 00000000000..b5832b23e70 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/cart_spec.rb @@ -0,0 +1,537 @@ +require 'spec_helper' + +require 'shared_examples/api_v2/base' +require 'shared_examples/api_v2/current_order' + +describe 'API V2 Storefront Cart Spec', type: :request do + let(:default_currency) { 'USD' } + let(:store) { create(:store, default_currency: default_currency) } + let(:currency) { store.default_currency } + let(:user) { create(:user) } + let(:order) { create(:order, user: user, store: store, currency: currency) } + let(:variant) { create(:variant) } + + include_context 'API v2 tokens' + + shared_examples 'coupon code error' do + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response[:error]).to eq("The coupon code you entered doesn't exist. Please try again.") + end + end + + shared_context 'coupon codes' do + let!(:line_item) { create(:line_item, order: order) } + let!(:shipment) { create(:shipment, order: order) } + let!(:promotion) { Spree::Promotion.create(name: 'Free shipping', code: 'freeship') } + let(:coupon_code) { promotion.code } + let!(:promotion_action) { Spree::PromotionAction.create(promotion_id: promotion.id, type: 'Spree::Promotion::Actions::FreeShipping') } + end + + describe 'cart#create' do + let(:order) { Spree::Order.last } + let(:execute) { post '/api/v2/storefront/cart', headers: headers } + + shared_examples 'creates an order' do + before { execute } + + it_behaves_like 'returns valid cart JSON' + it_behaves_like 'returns 201 HTTP status' + end + + shared_examples 'creates an order with different currency' do + before do + store.default_currency = 'EUR' + store.save! + execute + end + + it_behaves_like 'returns valid cart JSON' + it_behaves_like 'returns 201 HTTP status' + + it 'sets proper currency' do + expect(json_response['data']).to have_attribute(:currency).with_value('EUR') + end + end + + context 'as a signed in user' do + let(:headers) { headers_bearer } + + it_behaves_like 'creates an order' + it_behaves_like 'creates an order with different currency' + + context 'user association' do + before { execute } + + it 'associates order with user' do + expect(json_response['data']).to have_relationship(:user).with_data('id' => user.id.to_s, 'type' => 'user') + end + end + end + + context 'as a guest user' do + let(:headers) { {} } + + it_behaves_like 'creates an order' + it_behaves_like 'creates an order with different currency' + end + end + + describe 'cart#add_item' do + let(:options) { {} } + let(:params) { { variant_id: variant.id, quantity: 5, options: options, include: 'variants' } } + let(:execute) { post '/api/v2/storefront/cart/add_item', params: params, headers: headers } + + before do + Spree::PermittedAttributes.line_item_attributes << :cost_price + end + + shared_examples 'adds item' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'with success' do + expect(order.line_items.count).to eq(2) + expect(order.line_items.last.variant).to eq(variant) + expect(order.line_items.last.quantity).to eq(5) + expect(json_response['included']).to include(have_type('variant').and(have_id(variant.id.to_s))) + end + + context 'with options' do + let(:options) { { cost_price: 1.99 } } + + it 'sets custom attributes values' do + expect(order.line_items.last.cost_price).to eq(1.99) + end + end + end + + shared_examples 'doesnt add item with quantity unnavailble' do + before do + variant.stock_items.first.update(backorderable: false) + params[:quantity] = 11 + execute + end + + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response[:error]).to eq("Quantity selected of \"#{variant.name} (#{variant.options_text})\" is not available.") + end + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + context 'with existing order' do + it_behaves_like 'adds item' + it_behaves_like 'doesnt add item with quantity unnavailble' + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + context 'with existing order' do + it_behaves_like 'adds item' + it_behaves_like 'doesnt add item with quantity unnavailble' + end + + it_behaves_like 'no current order' + end + end + + describe 'cart#remove_line_item' do + let(:execute) { delete "/api/v2/storefront/cart/remove_line_item/#{line_item.id}", headers: headers } + + shared_examples 'removes line item' do + before { execute } + + context 'without line items' do + let!(:line_item) { create(:line_item) } + + it_behaves_like 'returns 404 HTTP status' + end + + context 'containing line item' do + let!(:line_item) { create(:line_item, order: order) } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'removes line item from the cart' do + expect(order.line_items.count).to eq(0) + end + end + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + context 'with existing order' do + it_behaves_like 'removes line item' + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + context 'with existing order' do + it_behaves_like 'removes line item' + end + + it_behaves_like 'no current order' + end + end + + describe 'cart#empty' do + let(:execute) { patch '/api/v2/storefront/cart/empty', headers: headers } + + shared_examples 'emptying the order' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'empties the order' do + expect(order.reload.line_items.count).to eq(0) + end + end + + context 'as a signed in user' do + context 'with existing order with line item' do + include_context 'creates order with line item' + + it_behaves_like 'emptying the order' + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + context 'with existing guest order with line item' do + include_context 'creates guest order with guest token' + + it_behaves_like 'emptying the order' + end + + it_behaves_like 'no current order' + end + end + + describe 'cart#set_quantity' do + let(:line_item) { create(:line_item, order: order) } + let(:params) { { order: order, line_item_id: line_item.id, quantity: 5 } } + let(:execute) { patch '/api/v2/storefront/cart/set_quantity', params: params, headers: headers } + + shared_examples 'wrong quantity parameter' do + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response[:error]).to eq('Quantity has to be greater than 0') + end + end + + shared_examples 'set quantity' do + context 'non-existing line item' do + before do + params[:line_item_id] = 9999 + execute + end + + it_behaves_like 'returns 404 HTTP status' + end + + context 'with insufficient stock quantity and non-backorderable item' do + before do + line_item.variant.stock_items.first.update(backorderable: false) + execute + end + + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response[:error]).to eq("Quantity selected of \"#{line_item.name}\" is not available.") + end + end + + context 'changes the quantity of line item' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'successfully changes the quantity' do + expect(line_item.reload.quantity).to eq(5) + end + end + + context '0 passed as quantity' do + before do + params[:quantity] = 0 + execute + end + + it_behaves_like 'wrong quantity parameter' + end + + context 'quantity not passed' do + before do + params[:quantity] = nil + execute + end + + it_behaves_like 'wrong quantity parameter' + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'set quantity' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'set quantity' + end + end + + describe 'cart#show' do + shared_examples 'showing the cart' do + before do + get '/api/v2/storefront/cart', headers: headers + end + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + end + + shared_examples 'showing 404' do + before do + get '/api/v2/storefront/cart', headers: headers + end + + it_behaves_like 'returns 404 HTTP status' + end + + context 'without existing order' do + let!(:headers) { headers_bearer } + + it_behaves_like 'showing 404' + end + + context 'with existing user order with line item' do + include_context 'creates order with line item' + + it_behaves_like 'showing the cart' + end + + context 'with existing guest order' do + include_context 'creates guest order with guest token' + + it_behaves_like 'showing the cart' + end + + context 'for specified currency' do + before do + store.update!(default_currency: 'EUR') + end + + context 'with matching currency' do + include_context 'creates guest order with guest token' + + it_behaves_like 'showing the cart' + + it 'includes the same currency' do + get '/api/v2/storefront/cart', headers: headers + expect(json_response['data']).to have_attribute(:currency).with_value('EUR') + end + end + end + + context 'with option: include' do + let(:bill_addr_params) { { include: 'billing_address' } } + let(:ship_addr_params) { { include: 'billing_address' } } + + include_context 'creates order with line item' + it_behaves_like 'showing the cart' + + it 'will return included bill_address' do + get '/api/v2/storefront/cart', params: bill_addr_params, headers: headers + expect(json_response[:included][0]).to have_attribute(:firstname).with_value(order.bill_address.firstname) + expect(json_response[:included][0]).to have_attribute(:lastname).with_value(order.bill_address.lastname) + expect(json_response[:included][0]).to have_attribute(:address1).with_value(order.bill_address.address1) + expect(json_response[:included][0]).to have_attribute(:address2).with_value(order.bill_address.address2) + expect(json_response[:included][0]).to have_attribute(:city).with_value(order.bill_address.city) + expect(json_response[:included][0]).to have_attribute(:zipcode).with_value(order.bill_address.zipcode) + expect(json_response[:included][0]).to have_attribute(:phone).with_value(order.bill_address.phone) + expect(json_response[:included][0]).to have_attribute(:state_name).with_value(order.bill_address.state_name_text) + expect(json_response[:included][0]).to have_attribute(:company).with_value(order.bill_address.company) + expect(json_response[:included][0]).to have_attribute(:country_name).with_value(order.bill_address.country_name) + expect(json_response[:included][0]).to have_attribute(:country_iso3).with_value(order.bill_address.country_iso3) + expect(json_response[:included][0]).to have_attribute(:state_code).with_value(order.bill_address.state_abbr) + end + + it 'will return included ship_address' do + get '/api/v2/storefront/cart', params: ship_addr_params, headers: headers + + expect(json_response[:included][0]).to have_attribute(:firstname).with_value(order.bill_address.firstname) + expect(json_response[:included][0]).to have_attribute(:lastname).with_value(order.bill_address.lastname) + expect(json_response[:included][0]).to have_attribute(:address1).with_value(order.bill_address.address1) + expect(json_response[:included][0]).to have_attribute(:address2).with_value(order.bill_address.address2) + expect(json_response[:included][0]).to have_attribute(:city).with_value(order.bill_address.city) + expect(json_response[:included][0]).to have_attribute(:zipcode).with_value(order.bill_address.zipcode) + expect(json_response[:included][0]).to have_attribute(:phone).with_value(order.bill_address.phone) + expect(json_response[:included][0]).to have_attribute(:state_name).with_value(order.bill_address.state_name_text) + expect(json_response[:included][0]).to have_attribute(:company).with_value(order.bill_address.company) + expect(json_response[:included][0]).to have_attribute(:country_name).with_value(order.bill_address.country_name) + expect(json_response[:included][0]).to have_attribute(:country_iso3).with_value(order.bill_address.country_iso3) + expect(json_response[:included][0]).to have_attribute(:state_code).with_value(order.bill_address.state_abbr) + end + end + end + + describe 'cart#apply_coupon_code' do + include_context 'coupon codes' + + let(:params) { { coupon_code: coupon_code, include: 'promotions' } } + let(:execute) { patch '/api/v2/storefront/cart/apply_coupon_code', params: params, headers: headers } + + shared_examples 'apply coupon code' do + before { execute } + + context 'with coupon code for free shipping' do + let(:adjustment_value) { -shipment.cost.to_f } + let(:adjustment_value_in_money) { Spree::Money.new(adjustment_value, currency: order.currency) } + + context 'applies coupon code correctly' do + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'changes the adjustment total' do + expect(json_response['data']).to have_attribute(:promo_total).with_value(adjustment_value.to_s) + expect(json_response['data']).to have_attribute(:display_promo_total).with_value(adjustment_value_in_money.to_s) + end + + it 'includes the promotion in the response' do + expect(json_response['included']).to include(have_type('promotion').and(have_id(promotion.id.to_s))) + expect(json_response['included']).to include(have_type('promotion').and(have_attribute(:amount).with_value(adjustment_value.to_s))) + expect(json_response['included']).to include(have_type('promotion').and(have_attribute(:display_amount).with_value(adjustment_value_in_money.to_s))) + expect(json_response['included']).to include(have_type('promotion').and(have_attribute(:code).with_value(promotion.code))) + end + end + + context 'does not apply the coupon code' do + let!(:coupon_code) { 'zxr' } + + it_behaves_like 'coupon code error' + end + end + + context 'without coupon code' do + context 'does not apply the coupon code' do + let!(:coupon_code) { '' } + + it_behaves_like 'coupon code error' + end + end + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + context 'with existing order' do + it_behaves_like 'apply coupon code' + end + + it_behaves_like 'no current order' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + context 'with existing order' do + it_behaves_like 'apply coupon code' + end + + it_behaves_like 'no current order' + end + end + + describe 'cart#remove_coupon_code' do + let(:params) { { include: 'promotions' } } + let(:execute) { delete "/api/v2/storefront/cart/remove_coupon_code/#{coupon_code}", params: params, headers: headers } + + include_context 'coupon codes' + + shared_examples 'remove coupon code' do + context 'with coupon code applied' do + before do + order.coupon_code = promotion.code + Spree::PromotionHandler::Coupon.new(order).apply + order.save! + end + + it 'has applied promotion' do + expect(order.promotions).to include(promotion) + end + + context 'removes coupon code correctly' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'changes the adjustment total to 0.0' do + expect(json_response['data']).to have_attribute(:adjustment_total).with_value(0.0.to_s) + end + + it 'doesnt includes the promotion in the response' do + expect(json_response['included']).not_to include(have_type('promotion')) + end + end + + context 'tries to remove not-applied promotion' do + let(:coupon_code) { 'something-else' } + + before { execute } + + it_behaves_like 'coupon code error' + end + end + + context 'without coupon code applied' do + context 'tries to remove not-applied promotion' do + before { execute } + + it_behaves_like 'coupon code error' + end + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'remove coupon code' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'remove coupon code' + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/checkout_spec.rb b/api/spec/requests/spree/api/v2/storefront/checkout_spec.rb new file mode 100644 index 00000000000..9ac48608ae5 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/checkout_spec.rb @@ -0,0 +1,722 @@ +require 'spec_helper' + +require 'shared_examples/api_v2/base' +require 'shared_examples/api_v2/current_order' + +describe 'API V2 Storefront Checkout Spec', type: :request do + let(:default_currency) { 'USD' } + let(:store) { create(:store, default_currency: default_currency) } + let(:currency) { store.default_currency } + let(:user) { create(:user) } + let(:order) { create(:order, user: user, store: store, currency: currency) } + let(:payment) { create(:payment, amount: order.total, order: order) } + let(:shipment) { create(:shipment, order: order) } + + let(:address) do + { + firstname: 'John', + lastname: 'Doe', + address1: '7735 Old Georgetown Road', + city: 'Bethesda', + phone: '3014445002', + zipcode: '20814', + state_id: state.id, + country_iso: country.iso + } + end + + let(:payment_source_attributes) do + { + number: '4111111111111111', + month: 1.month.from_now.month, + year: 1.month.from_now.year, + verification_value: '123', + name: 'Spree Commerce' + } + end + let(:payment_params) do + { + order: { + payments_attributes: [ + { + payment_method_id: payment_method.id + } + ] + }, + payment_source: { + payment_method.id.to_s => payment_source_attributes + } + } + end + + include_context 'API v2 tokens' + + describe 'checkout#next' do + let(:execute) { patch '/api/v2/storefront/checkout/next', headers: headers } + + shared_examples 'perform next' do + context 'without line items' do + before do + order.line_items.destroy_all + execute + end + + it_behaves_like 'returns 422 HTTP status' + + it 'cannot transition to address without a line item' do + expect(json_response['error']).to include(Spree.t(:there_are_no_items_for_this_order)) + end + end + + context 'with line_items and email' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'can transition an order to the next state' do + expect(order.reload.state).to eq('address') + expect(json_response['data']).to have_attribute(:state).with_value('address') + end + end + + context 'without payment info' do + before do + order.update_column(:state, 'payment') + execute + end + + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response['error']).to include(Spree.t(:no_payment_found)) + end + + it 'doesnt advance pass payment state' do + expect(order.reload.state).to eq('payment') + end + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'perform next' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'perform next' + end + end + + describe 'checkout#advance' do + let(:execute) { patch '/api/v2/storefront/checkout/advance', headers: headers } + + shared_examples 'perform advance' do + before do + order.update_column(:state, 'payment') + end + + context 'with payment data' do + before do + payment + execute + end + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'advances an order till complete or confirm step' do + expect(order.reload.state).to eq('confirm') + expect(json_response['data']).to have_attribute(:state).with_value('confirm') + end + end + + context 'without payment data' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'doesnt advance pass payment state' do + expect(order.reload.state).to eq('payment') + expect(json_response['data']).to have_attribute(:state).with_value('payment') + end + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'perform advance' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'perform advance' + end + end + + describe 'checkout#complete' do + let(:execute) { patch '/api/v2/storefront/checkout/complete', headers: headers } + + shared_examples 'perform complete' do + before do + order.update_column(:state, 'confirm') + end + + context 'with payment data' do + before do + payment + shipment + execute + end + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'completes an order' do + expect(order.reload.state).to eq('complete') + expect(order.completed_at).not_to be_nil + expect(json_response['data']).to have_attribute(:state).with_value('complete') + end + end + + context 'without payment data' do + before { execute } + + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response['error']).to include(Spree.t(:no_payment_found)) + end + + it 'doesnt completes an order' do + expect(order.reload.state).not_to eq('complete') + expect(order.completed_at).to be_nil + end + end + + it_behaves_like 'no current order' + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'perform complete' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'perform complete' + end + end + + describe 'checkout#update' do + let!(:country_zone) { create(:zone, name: 'CountryZone') } + let!(:state) { create(:state) } + let!(:country) { state.country } + let!(:stock_location) { create(:stock_location) } + + let!(:shipping_method) { create(:shipping_method, zones: [country_zone]) } + let!(:payment_method) { create(:credit_card_payment_method) } + + let(:execute) { patch '/api/v2/storefront/checkout', params: params, headers: headers } + + include_context 'creates order with line item' + + before do + allow_any_instance_of(Spree::PaymentMethod).to receive(:source_required?).and_return(false) + allow_any_instance_of(Spree::Order).to receive_messages(confirmation_required?: true) + allow_any_instance_of(Spree::Order).to receive_messages(payment_required?: true) + end + + shared_examples 'perform update' do + context 'addresses' do + let(:params) do + { + order: { + bill_address_attributes: address, + ship_address_attributes: address + } + } + end + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates addresses' do + order.reload + expect(order.bill_address).not_to be_nil + expect(order.ship_address).not_to be_nil + expect(order.bill_address.firstname).to eq address[:firstname] + expect(order.bill_address.lastname).to eq address[:lastname] + expect(order.bill_address.address1).to eq address[:address1] + expect(order.bill_address.city).to eq address[:city] + expect(order.bill_address.phone).to eq address[:phone] + expect(order.bill_address.zipcode).to eq address[:zipcode] + expect(order.bill_address.state_id).to eq address[:state_id] + expect(order.bill_address.country.iso).to eq address[:country_iso] + end + end + + context 'shipment' do + let!(:default_selected_shipping_rate_id) { shipment.selected_shipping_rate_id } + let(:new_selected_shipping_rate_id) { Spree::ShippingRate.last.id } + let!(:shipping_method) { shipment.shipping_method } + let!(:second_shipping_method) { create(:shipping_method, name: 'Fedex') } + + let(:params) do + { + order: { + shipments_attributes: { + '0' => { selected_shipping_rate_id: new_selected_shipping_rate_id, id: shipment.id } + } + } + } + end + + before do + shipment + shipment.add_shipping_method(second_shipping_method) + execute + end + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates shipment' do + shipment.reload + expect(shipment.shipping_rates.count).to eq(2) + expect(shipment.selected_shipping_rate_id).to eq(new_selected_shipping_rate_id) + expect(shipment.selected_shipping_rate_id).not_to eq(default_selected_shipping_rate_id) + expect(shipment.shipping_method).to eq(second_shipping_method) + end + end + + context 'payment' do + context 'payment method' do + let(:params) do + { + order: { + payments_attributes: [ + { + payment_method_id: payment_method.id + } + ] + } + } + end + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates payment method' do + expect(order.payments).not_to be_empty + expect(order.payments.first.payment_method_id).to eq payment_method.id + end + end + + context 'payment source' do + let(:params) { payment_params } + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates payment method with source' do + expect(order.payments).not_to be_empty + expect(order.payments.last.source.name).to eq('Spree Commerce') + expect(order.payments.last.source.last_digits).to eq('1111') + end + end + end + + context 'special instructions' do + let(:params) do + { + order: { + special_instructions: "Don't drop it" + } + } + end + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates the special instructions' do + expect(order.reload.special_instructions).to eq("Don't drop it") + end + + it 'returns updated special instructions' do + expect(json_response['data']).to have_attribute(:special_instructions).with_value("Don't drop it") + end + end + + context 'email' do + let(:params) do + { + order: { + email: 'guest@spreecommerce.org' + } + } + end + + before { execute } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'returns valid cart JSON' + + it 'updates email' do + expect(order.reload.email).to eq('guest@spreecommerce.org') + end + + it 'returns updated email' do + expect(json_response['data']).to have_attribute(:email).with_value('guest@spreecommerce.org') + end + end + + context 'with invalid params' do + let(:params) do + { + order: { + email: 'wrong_email' + } + } + end + + before do + order.update_column(:state, 'delivery') + execute + end + + it_behaves_like 'returns 422 HTTP status' + + it 'returns an error' do + expect(json_response['error']).to eq('Customer E-Mail is invalid') + end + + it 'returns validation errors' do + expect(json_response['errors']).to eq('email' => ['is invalid']) + end + end + + context 'without order' do + let(:params) { {} } + + it_behaves_like 'no current order' + end + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'perform update' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'perform update' + end + end + + describe 'checkout#add_store_credit' do + let(:order_total) { 500.00 } + let(:params) { { order_token: order.token } } + let(:execute) { post '/api/v2/storefront/checkout/add_store_credit', params: params, headers: headers } + + before do + create(:store_credit_payment_method) + execute + end + + context 'for guest or user without store credit' do + let!(:order) { create(:order, total: order_total) } + + it_behaves_like 'returns 422 HTTP status' + end + + context 'for user with store credits' do + let!(:store_credit) { create(:store_credit, amount: order_total) } + let!(:order) { create(:order, user: store_credit.user, total: order_total) } + + shared_examples 'valid payload' do |amount| + it 'returns StoreCredit payment' do + expect(json_response['data']).to have_relationship(:payments) + payment = Spree::Payment.find(json_response['data']['relationships']['payments']['data'][0]['id'].to_i) + expect(payment.amount).to eq amount + expect(payment.payment_method.class).to eq Spree::PaymentMethod::StoreCredit + end + end + + context 'with no amount param' do + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'valid payload', 500.0 + end + + context 'with amount params requested' do + let(:requested_amount) { 300.0 } + let(:params) { { order_token: order.token, amount: requested_amount } } + + it_behaves_like 'returns 200 HTTP status' + it_behaves_like 'valid payload', 300.0 + end + + context 'with option include' do + let!(:payment) { Spree::Payment.all.first } + + context 'payments.source' do + let(:execute) { post '/api/v2/storefront/checkout/add_store_credit?include=payments.source', params: params, headers: headers } + + it 'return relationship with store_credit' do + expect(json_response['included'][0]).to have_type('store_credit') + + expect(json_response['included'][0]).to have_attribute(:amount).with_value(payment.source.amount.to_s) + expect(json_response['included'][0]).to have_attribute(:amount_used).with_value(payment.source.amount_used.to_s) + expect(json_response['included'][0]).to have_attribute(:created_at) + + expect(json_response['included'][0]).to have_relationship(:category) + expect(json_response['included'][0]).to have_relationship(:store_credit_events) + expect(json_response['included'][0]).to have_relationship(:credit_type) + end + end + + context 'payments.payment_method' do + let(:execute) { post '/api/v2/storefront/checkout/add_store_credit?include=payments.payment_method', params: params, headers: headers } + + it 'return relationship with payment_method' do + expect(json_response['included'][0]).to have_type('payment_method') + + expect(json_response['included'][0]).to have_attribute(:type).with_value(payment.payment_method.type) + expect(json_response['included'][0]).to have_attribute(:name).with_value(payment.payment_method.name) + expect(json_response['included'][0]).to have_attribute(:description).with_value(payment.payment_method.description) + + expect(json_response['included'][1]).to have_relationship(:source) + expect(json_response['included'][1]).to have_relationship(:payment_method) + end + end + end + end + end + + describe 'checkout#remove_store_credit' do + let(:order_total) { 500.00 } + let(:params) { { order_token: order.token, include: 'payments', fields: { payment: 'state' } } } + let(:execute) { post '/api/v2/storefront/checkout/remove_store_credit', params: params, headers: headers } + let!(:store_credit) { create(:store_credit, amount: order_total) } + let!(:order) { create(:order, user: store_credit.user, total: order_total) } + + before do + create(:store_credit_payment_method) + Spree::Checkout::AddStoreCredit.call(order: order) + execute + end + + it_behaves_like 'returns 200 HTTP status' + + it 'returns no valid StoreCredit payment' do + expect(json_response['included'].empty?).to eq true + end + end + + describe 'checkout#payment_methods' do + let(:execute) { get '/api/v2/storefront/checkout/payment_methods', headers: headers } + let!(:payment_method) { create(:credit_card_payment_method) } + let(:payment_methods) { order.available_payment_methods } + + shared_examples 'returns a list of available payment methods' do + before { execute } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns valid payment methods JSON' do + expect(json_response['data']).not_to be_empty + expect(json_response['data'][0]).to have_id(payment_method.id.to_s) + expect(json_response['data'][0]).to have_type('payment_method') + expect(json_response['data'][0]).to have_attribute(:name).with_value(payment_method.name) + expect(json_response['data'][0]).to have_attribute(:description).with_value(payment_method.description) + expect(json_response['data'][0]).to have_attribute(:type).with_value(payment_method.type) + end + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'returns a list of available payment methods' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'returns a list of available payment methods' + end + end + + describe 'checkout#shipping_rates' do + let(:execute) { get '/api/v2/storefront/checkout/shipping_rates', headers: headers } + + let(:country) { Spree::Country.default } + let(:zone) { create(:zone, name: 'US') } + let(:shipping_method) { create(:shipping_method) } + let(:address) { create(:address, country: country) } + + let(:shipment) { order.shipments.first } + let(:shipping_rate) { shipment.selected_shipping_rate } + + shared_examples 'returns a list of shipments with shipping rates' do + before do + order.shipping_address = address + order.save! + zone.countries << country + shipping_method.zones = [zone] + order.create_proposed_shipments + execute + order.reload + end + + it_behaves_like 'returns 200 HTTP status' + + it 'returns valid shipments JSON' do + expect(json_response['data']).not_to be_empty + expect(json_response['data'].size).to eq(order.shipments.count) + expect(json_response['data'][0]).to have_id(shipment.id.to_s) + expect(json_response['data'][0]).to have_type('shipment') + expect(json_response['data'][0]).to have_relationships(:shipping_rates) + expect(json_response['included']).to be_present + expect(json_response['included'].size).to eq(shipment.shipping_rates.count) + shipment.shipping_rates.each do |shipping_rate| + expect(json_response['included']).to include(have_type('shipping_rate').and have_id(shipping_rate.id.to_s)) + end + expect(json_response['included'][0]).to have_id(shipping_rate.id.to_s) + expect(json_response['included'][0]).to have_type('shipping_rate') + expect(json_response['included'][0]).to have_attribute(:name).with_value(shipping_method.name) + expect(json_response['included'][0]).to have_attribute(:final_price).with_value(shipping_rate.final_price.to_s) + expect(json_response['included'][0]).to have_attribute(:display_final_price).with_value(shipping_rate.display_final_price.to_s) + expect(json_response['included'][0]).to have_attribute(:cost).with_value(shipping_rate.cost.to_s) + expect(json_response['included'][0]).to have_attribute(:display_cost).with_value(shipping_rate.display_cost.to_s) + expect(json_response['included'][0]).to have_attribute(:display_cost).with_value(shipping_rate.display_cost.to_s) + expect(json_response['included'][0]).to have_attribute(:tax_amount).with_value(shipping_rate.tax_amount.to_s) + expect(json_response['included'][0]).to have_attribute(:display_tax_amount).with_value(shipping_rate.display_tax_amount.to_s) + expect(json_response['included'][0]).to have_attribute(:shipping_method_id).with_value(shipping_method.id) + expect(json_response['included'][0]).to have_attribute(:selected).with_value(shipping_rate.selected) + expect(json_response['included'][0]).to have_attribute(:free).with_value(shipping_rate.free?) + end + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'returns a list of shipments with shipping rates' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'returns a list of shipments with shipping rates' + end + end + + describe 'full checkout flow' do + let!(:country) { create(:country) } + let(:state) { create(:state, country: country) } + let!(:shipping_method) do + create(:shipping_method).tap do |shipping_method| + shipping_method.zones = [zone] + end + end + let!(:zone) { create(:zone) } + let!(:zone_member) { create(:zone_member, zone: zone, zoneable: country) } + let!(:payment_method) { create(:credit_card_payment_method) } + + let(:customer_params) do + { + order: { + email: 'new@customer.org', + bill_address_attributes: address, + ship_address_attributes: address + } + } + end + + let(:shipment_params) do + { + order: { + shipments_attributes: { + '0' => { selected_shipping_rate_id: shipping_rate_id, id: shipment_id } + } + } + } + end + + let(:shipping_rate_id) do + json_response['data'].first['relationships']['shipping_rates']['data'].first['id'] + end + let(:shipment_id) { json_response['data'].first['id'] } + + shared_examples 'transitions through checkout from start to finish' do + before do + zone.countries << country + shipping_method.zones = [zone] + end + + it 'completes checkout' do + # we need to set customer information (email, billing & shipping address) + patch '/api/v2/storefront/checkout', params: customer_params, headers: headers + expect(response.status).to eq(200) + + # getting back shipping rates + get '/api/v2/storefront/checkout/shipping_rates', headers: headers + expect(response.status).to eq(200) + + # selecting shipping method + patch '/api/v2/storefront/checkout', params: shipment_params, headers: headers + expect(response.status).to eq(200) + + # getting back list of available payment methods + get '/api/v2/storefront/checkout/payment_methods', headers: headers + expect(response.status).to eq(200) + expect(json_response['data'].first['id']).to eq(payment_method.id.to_s) + + # creating a CC for selected payment method + patch '/api/v2/storefront/checkout', params: payment_params, headers: headers + expect(response.status).to eq(200) + + # complete the checkout + patch '/api/v2/storefront/checkout/complete', headers: headers + expect(response.status).to eq(200) + expect(order.reload.completed_at).not_to be_nil + expect(order.state).to eq('complete') + expect(order.shipments.first.shipping_method).to eq(shipping_method) + expect(order.payments.valid.first.payment_method).to eq(payment_method) + end + end + + context 'as a guest user' do + include_context 'creates guest order with guest token' + + it_behaves_like 'transitions through checkout from start to finish' + end + + context 'as a signed in user' do + include_context 'creates order with line item' + + it_behaves_like 'transitions through checkout from start to finish' + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/countries_spec.rb b/api/spec/requests/spree/api/v2/storefront/countries_spec.rb new file mode 100644 index 00000000000..6a01adaa49e --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/countries_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'Storefront API v2 Countries spec', type: :request do + let!(:country) { create(:country) } + let!(:states) { create_list(:state, 2, country: country) } + let!(:default_country) do + country = create(:country, iso3: 'GBR') + Spree::Config[:default_country_id] = country.id + country + end + shared_examples 'returns valid country resource JSON' do + it 'returns a valid country resource JSON response' do + expect(response.status).to eq(200) + + expect(json_response['data']).to have_type('country') + expect(json_response['data']).to have_relationships(:states) + + expect(json_response['data']).to have_attribute(:iso) + expect(json_response['data']).to have_attribute(:iso3) + expect(json_response['data']).to have_attribute(:iso_name) + expect(json_response['data']).to have_attribute(:name) + expect(json_response['data']).to have_attribute(:default) + expect(json_response['data']).to have_attribute(:states_required) + expect(json_response['data']).to have_attribute(:zipcode_required) + end + end + + describe 'countries#index' do + context 'general' do + + before { get '/api/v2/storefront/countries' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns all countries' do + expect(json_response['data'].size).to eq(Spree::Country.count) + expect(json_response['data'][0]).to have_type('country') + expect(json_response['data'][0]).not_to have_relationships(:states) + end + end + + context 'with shipping country filtering' do + let!(:new_country) { create(:country) } + let!(:zone) { create(:zone) } + let!(:shipping_method) { create(:shipping_method) } + let!(:shippable_url) { '/api/v2/storefront/countries?filter[shippable]=true' } + let!(:to_return) { shipping_method.zones.reduce([]) { |collection, zone| collection + zone.country_list } } + + it 'returns countries that the current store ships to' do + get shippable_url + expect(json_response['data'].size).to eq(to_return.size) + end + + it 'does not return country second time if it appears in multiple zones' do + zone.countries << country + shipping_method.zones << zone + get shippable_url + expect(json_response['data'].size).to eq(to_return.size) + end + + it 'returns countries from multiple shipping methods' do + new_country = create(:country) + new_shipping_method = create(:shipping_method) + new_shipping_method.zones.first.countries << new_country + to_return << new_country + get shippable_url + expect(json_response['data'].pluck(:id)).to include(new_country.id.to_s) + end + end + end + + describe 'country#show' do + context 'by iso' do + before do + get "/api/v2/storefront/countries/#{country.iso}" + end + + it_behaves_like 'returns valid country resource JSON' + + it 'returns country by iso' do + expect(json_response['data']).to have_id(country.id.to_s) + expect(json_response['data']).to have_attribute(:iso).with_value(country.iso) + expect(json_response['data']).to have_attribute(:iso3).with_value(country.iso3) + expect(json_response['data']).to have_attribute(:iso_name).with_value(country.iso_name) + expect(json_response['data']).to have_attribute(:name).with_value(country.name) + expect(json_response['data']).to have_attribute(:default).with_value(country == Spree::Country.default) + expect(json_response['data']).to have_attribute(:states_required).with_value(country.states_required) + expect(json_response['data']).to have_attribute(:zipcode_required).with_value(country.zipcode_required) + end + end + + context 'by iso3' do + before do + get "/api/v2/storefront/countries/#{country.iso3}" + end + + it_behaves_like 'returns valid country resource JSON' + + it 'returns country by iso3' do + expect(json_response['data']).to have_id(country.id.to_s) + expect(json_response['data']).to have_attribute(:iso3).with_value(country.iso3) + end + end + + context 'by "default"' do + before do + get '/api/v2/storefront/countries/default' + end + + it_behaves_like 'returns valid country resource JSON' + + it 'returns default country' do + expect(json_response['data']).to have_id(default_country.id.to_s) + expect(json_response['data']).to have_attribute(:iso).with_value(default_country.iso) + expect(json_response['data']).to have_attribute(:default).with_value(true) + end + end + + context 'with specified options' do + before { get "/api/v2/storefront/countries/#{country.iso}?include=states" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns country with included states' do + expect(json_response['data']).to have_id(country.id.to_s) + expect(json_response['included']).to include(have_type('state').and(have_attribute(:abbr).with_value(states.first.abbr))) + expect(json_response['included']).to include(have_type('state').and(have_attribute(:name).with_value(states.first.name))) + end + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/products_spec.rb b/api/spec/requests/spree/api/v2/storefront/products_spec.rb new file mode 100644 index 00000000000..ea1fd984786 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/products_spec.rb @@ -0,0 +1,226 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'API V2 Storefront Products Spec', type: :request do + let!(:products) { create_list(:product, 5) } + let(:taxon) { create(:taxon) } + let(:product_with_taxon) { create(:product, taxons: [taxon]) } + let(:product_with_name) { create(:product, name: 'Test Product') } + let(:product_with_price) { create(:product, price: 13.44) } + let!(:option_type) { create(:option_type) } + let!(:option_value) { create(:option_value, option_type: option_type) } + let(:product_with_option) { create(:product, option_types: [option_type]) } + let!(:variant) { create(:variant, product: product_with_option, option_values: [option_value]) } + let(:product) { create(:product) } + + describe 'products#index' do + context 'with no params' do + before { get '/api/v2/storefront/products' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns all products' do + expect(json_response['data'].count).to eq Spree::Product.count + expect(json_response['data'].first).to have_type('product') + end + end + + context 'with specified ids' do + before { get "/api/v2/storefront/products?filter[ids]=#{products.first.id}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified ids' do + expect(json_response['data'].count).to eq 1 + expect(json_response['data'].first).to have_id(products.first.id.to_s) + end + end + + context 'with specified price range' do + before { get "/api/v2/storefront/products?filter[price]=#{product_with_price.price.to_f},#{product_with_price.price.to_f + 0.04}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified price' do + expect(json_response['data'].first).to have_id(product_with_price.id.to_s) + expect(json_response['data'].first).to have_attribute(:price).with_value(product_with_price.price.to_f.to_s) + end + end + + context 'with specified taxon_ids' do + before { get "/api/v2/storefront/products?filter[taxons]=#{product_with_taxon.taxons.first.id}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified taxons' do + expect(json_response['data'].first).to have_id(product_with_taxon.id.to_s) + end + end + + context 'with specified name' do + before { get "/api/v2/storefront/products?filter[name]=#{product_with_name.name}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified name' do + expect(json_response['data'].first).to have_id(product_with_name.id.to_s) + expect(json_response['data'].first).to have_attribute(:name).with_value(product_with_name.name) + end + end + + context 'with specified options' do + before { get "/api/v2/storefront/products?filter[options][#{option_type.name}]=#{option_value.name}&include=option_types,variants.option_values" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified options' do + expect(json_response['data'].first).to have_id(product_with_option.id.to_s) + expect(json_response['included']).to include(have_type('option_type').and(have_attribute(:name).with_value(option_type.name))) + expect(json_response['included']).to include(have_type('option_value').and(have_attribute(:name).with_value(option_value.name))) + end + end + + context 'with specified multiple filters' do + before { get "/api/v2/storefront/products?filter[name]=#{product_with_name.name}&filter[price]=#{product_with_name.price.to_f - 0.02},#{product_with_name.price.to_f + 0.02}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products with specified name and price' do + expect(json_response['data'].count).to eq 1 + expect(json_response['data'].first).to have_id(product_with_name.id.to_s) + end + end + + context 'sort products' do + context 'sorting by price' do + context 'ascending order' do + before { get '/api/v2/storefront/products?sort=price' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products sorted by price' do + expect(json_response['data'].count).to eq Spree::Product.count + expect(json_response['data'].pluck(:id)).to eq Spree::Product.joins(master: :prices).select("#{Spree::Product.table_name}.*, #{Spree::Price.table_name}.amount").distinct.order("#{Spree::Price.table_name}.amount").map(&:id).map(&:to_s) + end + end + + context 'descending order' do + before { get '/api/v2/storefront/products?sort=-price' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products sorted by price with descending order' do + expect(json_response['data'].count).to eq Spree::Product.count + expect(json_response['data'].pluck(:id)).to eq Spree::Product.joins(master: :prices).select("#{Spree::Product.table_name}.*, #{Spree::Price.table_name}.amount").distinct.order("#{Spree::Price.table_name}.amount DESC").map(&:id).map(&:to_s) + end + end + end + + context 'sorting by updated_at' do + context 'ascending order' do + before { get '/api/v2/storefront/products?sort=updated_at' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products sorted by updated_at' do + expect(json_response['data'].count).to eq Spree::Product.count + expect(json_response['data'].pluck(:id)).to eq Spree::Product.order(:updated_at).pluck(:id).map(&:to_s) + end + end + + context 'descending order' do + before { get '/api/v2/storefront/products?sort=-updated_at' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns products sorted by updated_at with descending order' do + expect(json_response['data'].count).to eq Spree::Product.count + expect(json_response['data'].pluck(:id)).to eq Spree::Product.order(updated_at: :desc).pluck(:id).map(&:to_s) + end + end + end + end + + context 'paginate products' do + context 'with specified pagination params' do + before { get '/api/v2/storefront/products?page=1&per_page=2' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount products' do + expect(json_response['data'].count).to eq 2 + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq 2 + expect(json_response['meta']['total_count']).to eq Spree::Product.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/products?page=1&per_page=2') + expect(json_response['links']['next']).to include('/api/v2/storefront/products?page=2&per_page=2') + expect(json_response['links']['prev']).to include('/api/v2/storefront/products?page=1&per_page=2') + end + end + + context 'without specified pagination params' do + before { get '/api/v2/storefront/products' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount products' do + expect(json_response['data'].count).to eq Spree::Product.count + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq json_response['data'].count + expect(json_response['meta']['total_count']).to eq Spree::Product.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/products') + expect(json_response['links']['next']).to include('/api/v2/storefront/products?page=1') + expect(json_response['links']['prev']).to include('/api/v2/storefront/products?page=1') + end + end + end + end + + describe 'products#show' do + context 'with non-existing product' do + before { get '/api/v2/storefront/products/example' } + + it_behaves_like 'returns 404 HTTP status' + end + + context 'with existing product' do + before { get "/api/v2/storefront/products/#{product.slug}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns a valid JSON response' do + expect(json_response['data']).to have_id(product.id.to_s) + + expect(json_response['data']).to have_type('product') + + expect(json_response['data']).to have_attribute(:name).with_value(product.name) + expect(json_response['data']).to have_attribute(:description).with_value(product.description) + expect(json_response['data']).to have_attribute(:price).with_value(product.price.to_s) + expect(json_response['data']).to have_attribute(:currency).with_value(product.currency) + expect(json_response['data']).to have_attribute(:display_price).with_value(product.display_price.to_s) + expect(json_response['data']).to have_attribute(:available_on).with_value(product.available_on.as_json) + expect(json_response['data']).to have_attribute(:slug).with_value(product.slug) + expect(json_response['data']).to have_attribute(:meta_description).with_value(product.meta_description) + expect(json_response['data']).to have_attribute(:meta_keywords).with_value(product.meta_keywords) + expect(json_response['data']).to have_attribute(:updated_at).with_value(product.updated_at.as_json) + expect(json_response['data']).to have_attribute(:purchasable).with_value(product.purchasable?) + expect(json_response['data']).to have_attribute(:in_stock).with_value(product.in_stock?) + expect(json_response['data']).to have_attribute(:backorderable).with_value(product.backorderable?) + + expect(json_response['data']).to have_relationships( + :variants, :option_types, :product_properties, :default_variant + ) + end + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/sparse_fields_spec.rb b/api/spec/requests/spree/api/v2/storefront/sparse_fields_spec.rb new file mode 100644 index 00000000000..dba8abb8050 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/sparse_fields_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'API v2 JSON API Sparse Fields Spec', type: :request do + let!(:product) { create(:product_with_option_types) } + + shared_examples 'no sparse fields requested' do + it 'returns all attributes' do + expect(json_response['data']['attributes']).to be_present + expect(json_response['data']['attributes'].count > 3).to eq true + end + end + + context 'without fields param' do + before { get "/api/v2/storefront/products/#{product.id}" } + + it_behaves_like 'no sparse fields requested' + end + + context 'with empty fields param' do + before { get "/api/v2/storefront/products/#{product.id}?fields=" } + + it_behaves_like 'no sparse fields requested' + end + + context 'with proper fields param' do + before { get "/api/v2/storefront/products/#{product.id}?fields[product]=name,price,currency" } + + it 'filters resource attributes' do + expect(json_response['data']['attributes']).to be_present + expect(json_response['data']['attributes'].keys).to contain_exactly('name', 'price', 'currency') + end + end + + context 'with included resources' do + before { get "/api/v2/storefront/products/#{product.id}?include=option_types&fields[option_type]=name,presentation" } + + it 'filters resource attributes' do + expect(json_response['included']).to be_present + expect(json_response['included']).to include(have_type('option_type')) + expect(json_response['included'].first['attributes'].keys).to contain_exactly('name', 'presentation') + end + end +end diff --git a/api/spec/requests/spree/api/v2/storefront/taxons_spec.rb b/api/spec/requests/spree/api/v2/storefront/taxons_spec.rb new file mode 100644 index 00000000000..e4ed1619e45 --- /dev/null +++ b/api/spec/requests/spree/api/v2/storefront/taxons_spec.rb @@ -0,0 +1,152 @@ +require 'spec_helper' +require 'shared_examples/api_v2/base' + +describe 'Taxons Spec', type: :request do + let!(:taxonomy) { create(:taxonomy) } + let!(:taxons) { create_list(:taxon, 2, taxonomy: taxonomy, parent_id: taxonomy.root.id) } + + shared_examples 'returns valid taxon resource JSON' do + it 'returns a valid taxon resource JSON response' do + expect(response.status).to eq(200) + + expect(json_response['data']).to have_type('taxon') + expect(json_response['data']).to have_relationships(:parent, :taxonomy, :children, :products, :image) + end + end + + describe 'taxons#index' do + context 'with no params' do + before { get '/api/v2/storefront/taxons' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns all taxons' do + expect(json_response['data'].size).to eq(3) + expect(json_response['data'][0]).to have_type('taxon') + expect(json_response['data'][0]).to have_relationships(:parent, :children) + end + end + + context 'by roots' do + before { get '/api/v2/storefront/taxons?roots=true' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns taxons by roots' do + expect(json_response['data'].size).to eq(1) + expect(json_response['data'][0]).to have_type('taxon') + expect(json_response['data'][0]).to have_id(taxonomy.root.id.to_s) + expect(json_response['data'][0]).to have_relationship(:parent).with_data(nil) + expect(json_response['data'][0]).to have_relationships(:parent, :taxonomy, :children, :products, :image) + end + end + + context 'by parent' do + before { get "/api/v2/storefront/taxons?parent_id=#{taxonomy.root.id}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns children taxons by parent' do + expect(json_response['data'].size).to eq(2) + expect(json_response['data'][0]).to have_relationship(:parent).with_data('id' => taxonomy.root.id.to_s, 'type' => 'taxon') + expect(json_response['data'][1]).to have_relationship(:parent).with_data('id' => taxonomy.root.id.to_s, 'type' => 'taxon') + end + end + + context 'by taxonomy' do + before { get "/api/v2/storefront/taxons?taxonomy_id=#{taxonomy.id}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns taxons by taxonomy' do + expect(json_response['data'].size).to eq(3) + expect(json_response['data'][0]).to have_relationship(:taxonomy).with_data('id' => taxonomy.id.to_s, 'type' => 'taxonomy') + expect(json_response['data'][1]).to have_relationship(:taxonomy).with_data('id' => taxonomy.id.to_s, 'type' => 'taxonomy') + expect(json_response['data'][2]).to have_relationship(:taxonomy).with_data('id' => taxonomy.id.to_s, 'type' => 'taxonomy') + end + end + + context 'by ids' do + before { get "/api/v2/storefront/taxons?ids=#{taxons.map(&:id).join(',')}" } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns taxons by ids' do + expect(json_response['data'].size).to eq(2) + expect(json_response['data'].pluck(:id).sort).to eq(taxons.map(&:id).sort.map(&:to_s)) + end + end + + context 'paginate taxons' do + context 'with specified pagination params' do + before { get '/api/v2/storefront/taxons?page=1&per_page=1' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount of taxons' do + expect(json_response['data'].count).to eq 1 + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq 1 + expect(json_response['meta']['total_count']).to eq Spree::Taxon.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/taxons?page=1&per_page=1') + expect(json_response['links']['next']).to include('/api/v2/storefront/taxons?page=2&per_page=1') + expect(json_response['links']['prev']).to include('/api/v2/storefront/taxons?page=1&per_page=1') + end + end + + context 'without specified pagination params' do + before { get '/api/v2/storefront/taxons' } + + it_behaves_like 'returns 200 HTTP status' + + it 'returns specified amount of taxons' do + expect(json_response['data'].count).to eq Spree::Taxon.count + end + + it 'returns proper meta data' do + expect(json_response['meta']['count']).to eq json_response['data'].count + expect(json_response['meta']['total_count']).to eq Spree::Taxon.count + end + + it 'returns proper links data' do + expect(json_response['links']['self']).to include('/api/v2/storefront/taxons') + expect(json_response['links']['next']).to include('/api/v2/storefront/taxons?page=1') + expect(json_response['links']['prev']).to include('/api/v2/storefront/taxons?page=1') + end + end + end + end + + describe 'taxons#show' do + context 'by id' do + before do + get "/api/v2/storefront/taxons/#{taxons.first.id}" + end + + it_behaves_like 'returns valid taxon resource JSON' + + it 'returns taxon by id' do + expect(json_response['data']).to have_id(taxons.first.id.to_s) + expect(json_response['data']).to have_attribute(:name).with_value(taxons.first.name) + end + end + + context 'by permalink' do + before do + get "/api/v2/storefront/taxons/#{Spree::Taxon.first.permalink}" + end + + it_behaves_like 'returns valid taxon resource JSON' + + it 'returns taxon by permalink' do + expect(json_response['data']).to have_id(Spree::Taxon.first.id.to_s) + expect(json_response['data']).to have_attribute(:name).with_value(Spree::Taxon.first.name) + end + end + end +end diff --git a/api/spec/requests/spree/oauth/token_spec.rb b/api/spec/requests/spree/oauth/token_spec.rb new file mode 100644 index 00000000000..92387038a47 --- /dev/null +++ b/api/spec/requests/spree/oauth/token_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe 'Spree OAuth', type: :request do + let!(:user) { create(:user, email: 'new@user.com', password: 'secret', password_confirmation: 'secret') } + + describe 'get token' do + context 'by password' do + before do + allow(Spree.user_class).to receive(:find_for_database_authentication).with(hash_including(:email)) { user } + allow(user).to receive(:valid_for_authentication?).and_return(true) + post '/spree_oauth/token?grant_type=password&username=new@user.com&password=secret' + end + + it 'returns new token' do + expect(response.status).to eq(200) + expect(json_response).to have_attributes([:access_token, :token_type, :expires_in, :refresh_token, :created_at]) + expect(json_response['token_type']).to eq('Bearer') + end + end + end +end diff --git a/api/spec/requests/version_spec.rb b/api/spec/requests/version_spec.rb new file mode 100644 index 00000000000..53baa44513d --- /dev/null +++ b/api/spec/requests/version_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe 'Version', type: :request do + before { create_list(:country, 2) } + + describe '/api' do + it 'be a redirect' do + get '/api/countries' + expect(response).to have_http_status 301 + end + end + + describe '/api/v1' do + it 'be successful' do + get '/api/v1/countries' + expect(response).to have_http_status 200 + end + end +end diff --git a/api/spec/shared_examples/api_v2/base.rb b/api/spec/shared_examples/api_v2/base.rb new file mode 100644 index 00000000000..b07b8ce7f85 --- /dev/null +++ b/api/spec/shared_examples/api_v2/base.rb @@ -0,0 +1,13 @@ +shared_context 'API v2 tokens' do + let(:token) { Doorkeeper::AccessToken.create!(resource_owner_id: user.id, expires_in: nil) } + let(:headers_bearer) { { 'Authorization' => "Bearer #{token.token}" } } + let(:headers_order_token) { { 'X-Spree-Order-Token' => order.token } } +end + +[200, 201, 400, 404, 403, 422].each do |status_code| + shared_examples "returns #{status_code} HTTP status" do + it "returns #{status_code}" do + expect(response.status).to eq(status_code) + end + end +end diff --git a/api/spec/shared_examples/api_v2/current_order.rb b/api/spec/shared_examples/api_v2/current_order.rb new file mode 100644 index 00000000000..b1fd2da853e --- /dev/null +++ b/api/spec/shared_examples/api_v2/current_order.rb @@ -0,0 +1,73 @@ +def ensure_order_totals + order.update_totals + order.persist_totals +end + +shared_context 'creates order with line item' do + let!(:line_item) { create(:line_item, order: order, currency: currency) } + let!(:headers) { headers_bearer } + + before { ensure_order_totals } +end + +shared_context 'creates guest order with guest token' do + let(:guest_token) { 'guest_token' } + let!(:order) { create(:order, token: guest_token, store: store, currency: currency) } + let!(:line_item) { create(:line_item, order: order, currency: currency) } + let!(:headers) { headers_order_token } + + before { ensure_order_totals } +end + +shared_examples 'returns valid cart JSON' do + it 'returns a valid cart JSON response' do + order.reload + expect(json_response['data']).to be_present + expect(json_response['data']).to have_id(order.id.to_s) + expect(json_response['data']).to have_type('cart') + expect(json_response['data']).to have_attribute(:number).with_value(order.number) + expect(json_response['data']).to have_attribute(:state).with_value(order.state) + expect(json_response['data']).to have_attribute(:token).with_value(order.token) + expect(json_response['data']).to have_attribute(:total).with_value(order.total.to_s) + expect(json_response['data']).to have_attribute(:item_total).with_value(order.item_total.to_s) + expect(json_response['data']).to have_attribute(:ship_total).with_value(order.ship_total.to_s) + expect(json_response['data']).to have_attribute(:adjustment_total).with_value(order.adjustment_total.to_s) + expect(json_response['data']).to have_attribute(:included_tax_total).with_value(order.included_tax_total.to_s) + expect(json_response['data']).to have_attribute(:additional_tax_total).with_value(order.additional_tax_total.to_s) + expect(json_response['data']).to have_attribute(:display_additional_tax_total).with_value(order.display_additional_tax_total.to_s) + expect(json_response['data']).to have_attribute(:display_included_tax_total).with_value(order.display_included_tax_total.to_s) + expect(json_response['data']).to have_attribute(:tax_total).with_value(order.tax_total.to_s) + expect(json_response['data']).to have_attribute(:currency).with_value(order.currency.to_s) + expect(json_response['data']).to have_attribute(:email).with_value(order.email) + expect(json_response['data']).to have_attribute(:display_item_total).with_value(order.display_item_total.to_s) + expect(json_response['data']).to have_attribute(:display_ship_total).with_value(order.display_ship_total.to_s) + expect(json_response['data']).to have_attribute(:display_adjustment_total).with_value(order.display_adjustment_total.to_s) + expect(json_response['data']).to have_attribute(:display_tax_total).with_value(order.display_tax_total.to_s) + expect(json_response['data']).to have_attribute(:item_count).with_value(order.item_count) + expect(json_response['data']).to have_attribute(:special_instructions).with_value(order.special_instructions) + expect(json_response['data']).to have_attribute(:promo_total).with_value(order.promo_total.to_s) + expect(json_response['data']).to have_attribute(:display_promo_total).with_value(order.display_promo_total.to_s) + expect(json_response['data']).to have_attribute(:display_total).with_value(order.display_total.to_s) + expect(json_response['data']).to have_relationships(:user, :line_items, :variants, :billing_address, :shipping_address, :payments, :shipments, :promotions) + end +end + +shared_examples 'no current order' do + context "order doesn't exist" do + before do + order.destroy + execute + end + + it_behaves_like 'returns 404 HTTP status' + end + + context 'already completed order' do + before do + order.update_column(:completed_at, Time.current) + execute + end + + it_behaves_like 'returns 404 HTTP status' + end +end diff --git a/api/spec/shared_examples/protect_product_actions.rb b/api/spec/shared_examples/protect_product_actions.rb new file mode 100644 index 00000000000..6109f6e8b44 --- /dev/null +++ b/api/spec/shared_examples/protect_product_actions.rb @@ -0,0 +1,16 @@ +shared_examples 'modifying product actions are restricted' do + it 'cannot create a new product if not an admin' do + api_post :create, product: { name: 'Brand new product!' } + assert_unauthorized! + end + + it 'cannot update a product' do + api_put :update, id: product.to_param, product: { name: 'I hacked your store!' } + assert_unauthorized! + end + + it 'cannot delete a product' do + api_delete :destroy, id: product.to_param + assert_unauthorized! + end +end diff --git a/api/spec/spec_helper.rb b/api/spec/spec_helper.rb new file mode 100644 index 00000000000..d03487f89a6 --- /dev/null +++ b/api/spec/spec_helper.rb @@ -0,0 +1,71 @@ +if ENV['COVERAGE'] + # Run Coverage report + require 'simplecov' + SimpleCov.start 'rails' do + add_group 'Serializers', 'app/serializers' + add_group 'Libraries', 'lib/spree' + + add_filter '/bin/' + add_filter '/db/' + add_filter '/script/' + add_filter '/spec/' + add_filter '/lib/spree/api/testing_support/' + + coverage_dir "#{ENV['COVERAGE_DIR']}/api" if ENV['COVERAGE_DIR'] + end +end + +# This file is copied to spec/ when you run 'rails generate rspec:install' +ENV['RAILS_ENV'] ||= 'test' + +begin + require File.expand_path('../dummy/config/environment', __FILE__) +rescue LoadError + puts 'Could not load dummy application. Please ensure you have run `bundle exec rake test_app`' + exit +end + +require 'rspec/rails' +require 'ffaker' + +# Requires supporting ruby files with custom matchers and macros, etc, +# in spec/support/ and its subdirectories. +Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each { |f| require f } + +require 'spree/testing_support/factories' +require 'spree/testing_support/preferences' +require 'spree/testing_support/image_helpers' + +require 'spree/api/testing_support/caching' +require 'spree/api/testing_support/helpers' +require 'spree/api/testing_support/setup' + +RSpec.configure do |config| + config.backtrace_exclusion_patterns = [/gems\/activesupport/, /gems\/actionpack/, /gems\/rspec/] + config.color = true + config.default_formatter = 'doc' + config.fail_fast = ENV['FAIL_FAST'] || false + config.infer_spec_type_from_file_location! + config.raise_errors_for_deprecations! + config.use_transactional_fixtures = true + + config.include JSONAPI::RSpec + config.include FactoryBot::Syntax::Methods + config.include Spree::Api::TestingSupport::Helpers, type: :controller + config.include Spree::Api::TestingSupport::Helpers, type: :request + config.extend Spree::Api::TestingSupport::Setup, type: :controller + config.include Spree::TestingSupport::Preferences, type: :controller + config.include Spree::TestingSupport::ImageHelpers + + config.before do + Spree::Api::Config[:requires_authentication] = true + end + + config.include VersionCake::TestHelpers, type: :controller + config.before(:each, type: :controller) do + set_request_version('', 1) + end + + config.order = :random + Kernel.srand config.seed +end diff --git a/api/spec/support/controller_hacks.rb b/api/spec/support/controller_hacks.rb new file mode 100644 index 00000000000..482114a053d --- /dev/null +++ b/api/spec/support/controller_hacks.rb @@ -0,0 +1,40 @@ +require 'active_support/all' +module ControllerHacks + extend ActiveSupport::Concern + + included do + routes { Spree::Core::Engine.routes } + end + + def api_get(action, params = {}, session = nil, flash = nil) + api_process(action, params, session, flash, 'GET') + end + + def api_post(action, params = {}, session = nil, flash = nil) + api_process(action, params, session, flash, 'POST') + end + + def api_put(action, params = {}, session = nil, flash = nil) + api_process(action, params, session, flash, 'PUT') + end + + def api_delete(action, params = {}, session = nil, flash = nil) + api_process(action, params, session, flash, 'DELETE') + end + + def api_process(action, params = {}, session = nil, flash = nil, method = 'get') + scoping = respond_to?(:resource_scoping) ? resource_scoping : {} + process( + action, + method: method, + params: params.merge(scoping), + session: session, + flash: flash, + format: :json + ) + end +end + +RSpec.configure do |config| + config.include ControllerHacks, type: :controller +end diff --git a/api/spec/support/database_cleaner.rb b/api/spec/support/database_cleaner.rb new file mode 100644 index 00000000000..542a5874525 --- /dev/null +++ b/api/spec/support/database_cleaner.rb @@ -0,0 +1,14 @@ +RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + config.before do + DatabaseCleaner.start + end + + config.after do + DatabaseCleaner.clean + end +end diff --git a/api/spec/support/have_attributes_matcher.rb b/api/spec/support/have_attributes_matcher.rb new file mode 100644 index 00000000000..b2ca174c45d --- /dev/null +++ b/api/spec/support/have_attributes_matcher.rb @@ -0,0 +1,8 @@ +RSpec::Matchers.define :have_attributes do |expected_attributes| + match do |actual| + # actual is a Hash object representing an object, like this: + # { "name" => "Product #1" } + actual_attributes = actual.keys.map(&:to_sym) + expected_attributes.map(&:to_sym).all? { |attr| actual_attributes.include?(attr) } + end +end diff --git a/api/spree_api.gemspec b/api/spree_api.gemspec new file mode 100644 index 00000000000..6ba529099a8 --- /dev/null +++ b/api/spree_api.gemspec @@ -0,0 +1,27 @@ +# -*- encoding: utf-8 -*- +require_relative '../core/lib/spree/core/version.rb' + +Gem::Specification.new do |s| + s.authors = ["Ryan Bigg"] + s.email = ["ryan@spreecommerce.com"] + s.description = %q{Spree's API} + s.summary = %q{Spree's API} + s.homepage = 'http://spreecommerce.org' + s.license = 'BSD-3-Clause' + + s.required_ruby_version = '>= 2.3.3' + + s.files = `git ls-files`.split($\).reject { |f| f.match(/^spec/) && !f.match(/^spec\/fixtures/) } + s.executables = s.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + s.name = "spree_api" + s.require_paths = ["lib"] + s.version = Spree.version + + s.add_development_dependency 'jsonapi-rspec' + + s.add_dependency 'spree_core', s.version + s.add_dependency 'rabl', '~> 0.13.1' + s.add_dependency 'versioncake', '~> 3.4.0' + s.add_dependency 'fast_jsonapi', '~> 1.5' + s.add_dependency 'doorkeeper', '~> 5.0' +end diff --git a/app.json b/app.json new file mode 100644 index 00000000000..5310df6d939 --- /dev/null +++ b/app.json @@ -0,0 +1,28 @@ +{ + "name": "Spree Sandbox", + "description":"demo store built with Spree", + "repository": "https://github.com/spree/spree", + "logo": "http://guides.spreecommerce.org/images/logo.png", + "keywords": [ + "spree" + ], + "buildpacks": [ + {"url": "https://github.com/spark-solutions/heroku-buildpack-spree.git"} + ], + "addons": [ + "heroku-postgresql:hobby-dev" + ], + "scripts": { + "postdeploy": "bundle exec rake db:migrate && bundle exec rake db:seed && SKIP_SAMPLE_IMAGES=true bundle exec rake spree_sample:load" + }, + "env": { + "ADMIN_EMAIL": { + "description": "We will create an admin user with this email.", + "value": "spree@example.com" + }, + "ADMIN_PASSWORD": { + "description": "We will create an admin user with this password.", + "value": "spree123" + } + } +} diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb deleted file mode 100644 index e024bcc2e43..00000000000 --- a/app/controllers/account_controller.rb +++ /dev/null @@ -1,48 +0,0 @@ -class AccountController < Spree::BaseController - # Be sure to include AuthenticationSystem in Application Controller instead - include AuthenticatedSystem - # If you want "remember me" functionality, add this before_filter to Application Controller - before_filter :login_from_cookie - - layout 'admin' - - def index - redirect_to(:action => 'signup') unless logged_in? || User.count > 0 - end - - def login - return unless request.post? - self.current_user = User.authenticate(params[:login], params[:password]) - if logged_in? - if params[:remember_me] == "1" - self.current_user.remember_me - cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at } - end - redirect_back_or_default(:controller => '/account', :action => 'index') - flash.now[:notice] = "Logged in successfully" - else - flash.now[:error] = "Login authentication failed." - end - end - - def signup - @user = User.new(params[:user]) - return unless request.post? - @user.save! - self.current_user = @user - redirect_back_or_default(:controller => '/account', :action => 'index') - flash[:notice] = "Thanks for signing up!" - rescue ActiveRecord::RecordInvalid - flash[:error] = "Problem creating user account." - render :action => 'signup' - end - - def logout - self.current_user.forget_me if logged_in? - cookies.delete :auth_token - reset_session - flash[:notice] = "You have been logged out." - redirect_back_or_default('/') - #redirect_back_or_default(:controller => '/account', :action => 'index') - end -end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb deleted file mode 100644 index b7cde219885..00000000000 --- a/app/controllers/admin/base_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -# this clas was inspired (heavily) from the mephisto admin architecture - -class Admin::BaseController < Spree::BaseController - before_filter :login_required - helper :search - layout 'admin' -end diff --git a/app/controllers/admin/categories_controller.rb b/app/controllers/admin/categories_controller.rb deleted file mode 100644 index 9b813b13c6d..00000000000 --- a/app/controllers/admin/categories_controller.rb +++ /dev/null @@ -1,83 +0,0 @@ -class Admin::CategoriesController < Admin::BaseController - - def index - list - render :action => 'list' - end - - def list - if params[:id] - @categories = Category.find(:all, :conditions=>["parent_id = ?", params[:id]], :page => {:size => 10, :current =>params[:page], :first => 1}) - @parent_category = Category.find(params[:id]) - else - @categories = Category.find(:all, :conditions=>["parent_id IS NULL"], :page => {:size => 10, :current =>params[:page], :first => 1}) - end - end - - def new - load_data - if request.post? - @category = Category.new(params[:category]) - @category.tax_treatments = TaxTreatment.find(params[:tax_treatments]) if params[:tax_treatments] - if @category.save - flash[:notice] = 'Category was successfully created.' - redirect_to :action => 'list' - end - else - @category = Category.new - #@category.parent = @all_categories.first - end - end - - def edit - load_data - @category = Category.find(params[:id]) - end - - def update - @category = Category.find(params[:id]) - # clear out previous tax treatments since deselecting them will not communicated via POST - @category.tax_treatments.clear - @category.tax_treatments = TaxTreatment.find(params[:tax_treatments]) if params[:tax_treatments] - @category.save - - if @category.update_attributes(params[:category]) - flash[:notice] = 'Category was successfully updated.' - redirect_to :action => 'index' - else - render :action => 'edit' - end - end - - def destroy - category=Category.find(params[:id]) - category.destroy - flash[:notice] = "Category was successfully destroyed." - redirect_to :action => 'list' - end - - # AJAX method to show tax treatments based on change in parent category - def tax_treatments - category = Category.find_or_initialize_by_id(params[:category_id]) - if params[:parent_id].blank? - category.parent = nil - else - category.parent = Category.find(params[:parent_id]) - end - - @all_tax_treatments = TaxTreatment.find(:all) - - render :partial => 'shared/tax_treatments', - :locals => {:tax_treatments => @all_tax_treatments, :selected_treatments => category.tax_treatments}, - :layout => false - end - - private - - def load_data - @all_categories = Category.find(:all, :order=>"name") - @all_categories.unshift Category.new(:name => "") - @all_tax_treatments = TaxTreatment.find(:all) - end -end - \ No newline at end of file diff --git a/app/controllers/admin/images_controller.rb b/app/controllers/admin/images_controller.rb deleted file mode 100644 index cd2baca4977..00000000000 --- a/app/controllers/admin/images_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -class Admin::ImagesController < Admin::BaseController - - def new - @image = Image.new - render :layout => false - end - - def delete - image = Image.find(params[:id]) - viewable = image.viewable - image.destroy - render :partial => 'shared/images', :locals => {:viewable => viewable} - end -end diff --git a/app/controllers/admin/inventory_units_controller.rb b/app/controllers/admin/inventory_units_controller.rb deleted file mode 100644 index efa5e759883..00000000000 --- a/app/controllers/admin/inventory_units_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -class Admin::InventoryUnitsController < Admin::BaseController - require_role "admin" - - def adjust - @variant = Variant.find(params[:id]) - if request.post? - @level = InventoryLevel.new(params[:level]) - @level.on_hand = @variant.inventory(InventoryUnit::Status::ON_HAND) - - begin - #throw "Invalid Adjustment Quantity" unless @level.valid? - InventoryUnit.create_on_hand(@variant, @level.adjustment) if @level.adjustment > 0 - InventoryUnit.destroy_on_hand(@variant, @level.adjustment.abs) if @level.adjustment < 0 - flash.now[:notice] = "Inventory level has been adjusted." - @variant.reload - @level = InventoryLevel.new(:adjustment => 0) - rescue - flash.now[:error] = "Error occurred while updating inventory." - flash.now[:error] = "Invalid adjustment quantity" unless @level.errors.empty? - end - else - @level = InventoryLevel.new(:adjustment => 0) - end - end - -end - diff --git a/app/controllers/admin/option_types_controller.rb b/app/controllers/admin/option_types_controller.rb deleted file mode 100644 index 755289d754c..00000000000 --- a/app/controllers/admin/option_types_controller.rb +++ /dev/null @@ -1,70 +0,0 @@ -class Admin::OptionTypesController < Admin::BaseController - def select - @product = Product.find(params[:id]) - @option_types = OptionType.find(:all) - selected_option_types = [] - @product.selected_options.each do |so| - selected_option_types << so.option_type - end - @option_types.delete_if {|ot| selected_option_types.include? ot} - - render :layout => false - end - - def index - @option_types = OptionType.find(:all) - end - - def new - if request.post? - @option_type = OptionType.new(params['option_type']) - if @option_type.save - flash[:notice] = 'Option type was successfully created.' - redirect_to :action => 'index' - else - logger.error("unable to create new option type: #{@option_type.inspect}") - flash[:error] = 'Problem saving new option type.' - render :action => 'new' - end - else - @option_type = OptionType.new - end - end - - def edit - @option_type = OptionType.find(params[:id]) - if request.post? - success = @option_type.update_attributes(params[:option_type]) - if success and params[:option_value] - option_value = OptionValue.new(params[:option_value]) - @option_type.option_values << option_value - success = @option_type.save - end - flash[:notice] = 'Option type was successfully updated.' if success - flash[:error] = "Problem updating option type." if not success - redirect_to :action => 'edit', :id => @option_type - end - end - - def delete - Option.delete(params[:id]) - redirect_to :action => 'index' - end - - #AJAX support method - def new_option_value - @option_type = OptionType.find(params[:id]) - render :partial => 'new_option_value', - :locals => {:option_type => @option_type}, - :layout => false - end - - #AJAX support method - def delete_option_value - OptionValue.delete(params[:option_value_id]) - @option_type = OptionType.find(params[:id]) - render :partial => 'option_values', - :locals => {:option_type => @option_type}, - :layout => false - end -end \ No newline at end of file diff --git a/app/controllers/admin/orders_controller.rb b/app/controllers/admin/orders_controller.rb deleted file mode 100644 index 41d5e65311f..00000000000 --- a/app/controllers/admin/orders_controller.rb +++ /dev/null @@ -1,272 +0,0 @@ -class Admin::OrdersController < Admin::BaseController - require_role "admin" - - in_place_edit_for :address, :firstname - in_place_edit_for :address, :lastname - in_place_edit_for :address, :address1 - in_place_edit_for :address, :address2 - in_place_edit_for :address, :city - in_place_edit_for :address, :zipcode - in_place_edit_for :address, :phone - in_place_edit_for :user, :email - - def index - @status_options = Order::Status.constants - if params[:search] - @search = SearchCriteria.new(params[:search]) - if @search.valid? - p = {} - conditions = build_conditions(p) - if p.empty? - @orders = Order.find(:all, :order => "created_at DESC", :page => {:size => 15, :current =>params[:page], :first => 1}) - else - @orders = Order.find(:all, - :order => "orders.created_at DESC", - :joins => "as orders inner join addresses as a on orders.bill_address_id = a.id", - :conditions => [conditions, p], - :select => "orders.*", - :page => {:size => 15, :current =>params[:page], :first => 1}) - end - else - @orders = [] - flash.now[:error] = "Invalid search criteria. Please check your results." - end - else - @search = SearchCriteria.new - @orders = Order.find(:all, - :order => "created_at DESC", - :conditions => ["status != ?", Order::Status::INCOMPLETE], - :page => {:size => 15, :current =>params[:page], :first => 1}) - end - end - - def show - @order = Order.find(params[:id]) - @user = @order.user - @states = State.find(:all) - @countries = Country.find(:all) - end - - def capture - order = Order.find(params[:id]) - - response = gateway_capture(order) - unless response.success? - flash[:error] = "Problem capturing credit card ... \n#{response.params['error']}" - redirect_to :back and return - end - - order.status = Order::Status::CAPTURED - order.order_operations << OrderOperation.new( - :operation_type => OrderOperation::OperationType::CAPTURE, - :user => current_user - ) - - if order.save - flash[:notice] = "Order has been captured." - else - logger.error "unable to update order status: " + order.inspect - flash[:error] = "Order was captured but database update has failed. Please ask your administrator to manually adjust order status." - end - redirect_to :back - end - - def ship - order = Order.find(params[:id]) - - # if the current status is AUTHORIZED then we need to CAPTURE as well - if order.status == Order::Status::AUTHORIZED - response = gateway_capture(order) - unless response.success? - flash[:error] = "Problem capturing credit card ... \n#{response.params['error']}" - redirect_to :back and return - end - end - - order.status = Order::Status::SHIPPED - order.order_operations << OrderOperation.new( - :operation_type => OrderOperation::OperationType::SHIP, - :user => current_user - ) - - begin - Order.transaction do - order.save! - # now update the inventory to reflect the new shipped status - order.inventory_units.each do |unit| - unit.update_attributes(:status => InventoryUnit::Status::SHIPPED) - end - end - flash[:notice] = "Order has been shipped." - rescue - logger.error "unable to ship order: " + order.inspect - flash[:error] = "Unable to ship order. Please contact your administrator." - end - - redirect_to :back - - end - - def cancel - order = Order.find(params[:id]) - response = gateway_void(order) - - unless response.success? - flash[:error] = "Problem voiding credit card authorization ... \n#{response.params['error']}" - redirect_to :back and return - end - - order.order_operations << OrderOperation.new( - :operation_type => OrderOperation::OperationType::CANCEL, - :user => current_user - ) - order.status = Order::Status::CANCELED - - begin - Order.transaction do - order.save! - # now update the inventory to reflect the new on hand status - order.inventory_units.each do |unit| - unit.update_attributes(:status => InventoryUnit::Status::ON_HAND) - end - flash[:notice] = "Order cancelled successfully." - end - rescue - logger.error "unable to cancel order: " + order.inspect - flash[:error] = "Unable to cancel order." - end - # send email confirmation - OrderMailer.deliver_cancel(order) - - redirect_to :back - end - - def return - order = Order.find(params[:id]) - - # TODO - consider making the credit an option since it may not be supported by some gateways - response = gateway_credit(order) - - unless response.success? - flash[:error] = "Problem crediting the credit card ... \n#{response.params['error']}" - redirect_to :back and return - end - - order.order_operations << OrderOperation.new( - :operation_type => OrderOperation::OperationType::RETURN, - :user => current_user - ) - order.status = Order::Status::RETURNED - - begin - Order.transaction do - order.save! - # now update the inventory to reflect the new on hand status - order.inventory_units.each do |unit| - unit.update_attributes(:status => InventoryUnit::Status::ON_HAND) - end - flash[:notice] = "Order successfully returned." - end - rescue - logger.error "unable to return order: " + order.inspect - flash[:error] = "Order payment was credited but database update has failed. Please ask your administrator to manually adjust order status." - end - - redirect_to :back - end - - def resend - # resend the order receipt - @order = Order.find(params[:id]) - OrderMailer.deliver_confirm(@order, true) - flash[:notice] = "Confirmation message was resent successfully." - redirect_to :back - end - - def delete - # delete an incomplete order from the system - order = Order.find(params[:id]) - if order.destroy - flash[:notice] = "Order successfully deleted." - else - logger.error "unable to delete order: " + order.inspect - flash[:error] = "Unable to delete order." - end - redirect_to :back - end - - private - def gateway_capture(order) - authorization = find_authorization(order) - gw = payment_gateway - response = gw.capture(order.total * 100, authorization.response_code, Order.minimal_gateway_options(order)) - return unless response.success? - order.credit_card.txns << Txn.new( - :amount => order.total, - :response_code => response.authorization, - :txn_type => Txn::TxnType::CAPTURE - ) - order.save - response - end - - def gateway_void(order) - authorization = find_authorization(order) - gw = payment_gateway - response = gw.void(authorization.response_code, Order.minimal_gateway_options(order)) - return unless response.success? - order.credit_card.txns << Txn.new( - :amount => order.total, - :response_code => response.authorization, - :txn_type => Txn::TxnType::VOID - ) - order.save - response - end - - def gateway_credit(order) - authorization = find_authorization(order) - gw = payment_gateway - response = gw.credit(order.total, authorization.response_code, Order.minimal_gateway_options(order)) - return unless response.success? - order.credit_card.txns << Txn.new( - :amount => order.total, - :response_code => response.authorization, - :txn_type => Txn::TxnType::CREDIT - ) - order.save - response - end - - def find_authorization(order) - #find the transaction associated with the original authorization/capture - cc = order.credit_card - cc.txns.find(:first, - :conditions => ["txn_type = ? or txn_type = ?", Txn::TxnType::AUTHORIZE, Txn::TxnType::CAPTURE], - :order => 'created_at DESC') - end - - def build_conditions(p) - c = [] - if not @search.start.blank? - c << "(orders.created_at between :start and :stop)" - p.merge! :start => @search.start.to_date - @search.stop = Date.today + 1 if @search.stop.blank? - p.merge! :stop => @search.stop.to_date + 1.day - end - unless @search.order_num.blank? - c << "number like :order_num" - p.merge! :order_num => @search.order_num + "%" - end - unless @search.customer.blank? - c << "(firstname like :customer or lastname like :customer)" - p.merge! :customer => @search.customer + "%" - end - if @search.status - c << "status = :status" - p.merge! :status => @search.status - end - (c.to_sentence :skip_last_comma=>true).gsub(",", " and ") - end - -end diff --git a/app/controllers/admin/overview_controller.rb b/app/controllers/admin/overview_controller.rb deleted file mode 100644 index 84efb2c1540..00000000000 --- a/app/controllers/admin/overview_controller.rb +++ /dev/null @@ -1,14 +0,0 @@ -# this clas was inspired (heavily) from the mephisto admin architecture - -class Admin::OverviewController < Admin::BaseController - #todo, add rss feed of information that is happening - - def index - @users = User.find(:all) - #@users = User.find_with_deleted(:all, :order => 'updated_at desc') -# going to list today's orders, yesterday's orders, older orders -# have a filter / search at the top - # @orders, @ - end - -end diff --git a/app/controllers/admin/products_controller.rb b/app/controllers/admin/products_controller.rb deleted file mode 100644 index 1b96a93c70e..00000000000 --- a/app/controllers/admin/products_controller.rb +++ /dev/null @@ -1,191 +0,0 @@ -class Admin::ProductsController < Admin::BaseController - require_role "admin" - - def index - if params[:search] - @search = SearchCriteria.new(params[:search]) - if @search.valid? - p = {} - conditions = build_conditions(p) - if p.empty? - @products = Product.find(:all, :order => "created_at DESC", :page => {:size => 10, :current =>params[:page], :first => 1}) - else - @products = Product.find(:all, - :order => "products.name", - :conditions => [conditions, p], - :include => :variants, - :page => {:size => 10, :current =>params[:page], :first => 1}) - end - else - @orders = [] - flash.now[:error] = "Invalid search criteria. Please check your results." - end - else - @search = SearchCriteria.new - @products = Product.find(:all, :page => {:size => 10, :current =>params[:page], :first => 1}) - end - end - - def show - @product = Product.find(params[:id]) - end - - def new - load_data - if request.post? - @product = Product.new(params[:product]) - @product.category = Category.find(params[:category]) unless params[:category].blank? - - @sku = params[:sku] - @on_hand = params[:on_hand] - - if @product.save - # create a sku (if one has been supplied) - @product.variants.first.update_attributes(:sku => @sku) if @sku - InventoryUnit.create_on_hand(@product.variants.first, @on_hand.to_i) if @on_hand - - #can't create tagging associations until product is saved - unless params[:tags].blank? - begin - @product.tag_with params[:tags] - rescue Tag::Error - flash.now[:error] = "Tag cannot contain special characters." - return - end - end - flash[:notice] = 'Product was successfully created.' - redirect_to :action => :edit, :id => @product - else - flash.now[:error] = "Problem saving new product #{@product}" - end - else - @product = Product.new - end - end - - def edit - if request.post? - load_data - @product = Product.find(params[:id]) - category_id = params[:category] - @product.category = (category_id.blank? ? nil : Category.find(params[:category])) - - if params[:variant] - @product.variants.update params[:variant].keys, params[:variant].values - end - - # need to clear this every time in case user removes tags (those won't show up in the post) - @product.taggings.clear - - unless params[:tags].blank? - begin - @product.tag_with params[:tags] - rescue Tag::Error - flash.now[:error] = "Tag cannot contain special characters." - return - end - end - - if params[:image] - @product.images << Image.new(params[:image]) - end - - @product.tax_treatments = TaxTreatment.find(params[:tax_treatments]) if params[:tax_treatments] - @product.save - - if @product.update_attributes(params[:product]) - flash[:notice] = 'Product was successfully updated.' - redirect_to :action => 'edit', :id => @product - else - flash.now[:error] = 'Problem updating product.' - end - else - @product = Product.find(params[:id]) - load_data - @selected_category = @product.category.id if @product.category - end - end - - def destroy - flash[:notice] = 'Product was successfully deleted.' - @product = Product.find(params[:id]) - @product.destroy - redirect_to :action => 'index' - end - - #AJAX support method - def add_option_type - @product = Product.find(params[:id]) - pot = ProductOptionType.new(:product => @product, :option_type => OptionType.find(params[:option_type_id])) - @product.selected_options << pot - @product.save - render :partial => 'option_types', - :locals => {:product => @product}, - :layout => false - end - - #AJAX support method - def remove_option_type - ProductOptionType.delete(params[:product_option_type_id]) - @product = Product.find(params[:id]) - render :partial => 'option_types', - :locals => {:product => @product}, - :layout => false - end - - # AJAX method to show tax treatments based on change in category - def tax_treatments - product = Product.find_or_initialize_by_id(params[:id]) - if params[:category_id].blank? - product.category = nil - else - product.category = Category.find(params[:category_id]) - end - @all_tax_treatments = TaxTreatment.find(:all) - render :partial => 'shared/tax_treatments', - :locals => {:tax_treatments => @all_tax_treatments, :selected_treatments => product.tax_treatments}, - :layout => false - end - - #AJAX method - def new_variant - @product = Product.find(params[:id]) - @variant = Variant.new - render :partial => 'new_variant', - :locals => {:product => @product}, - :layout => false - end - - #AJAX method - def delete_variant - @product = Product.find(params[:id]) - Variant.destroy(params[:variant_id]) - flash.now[:notice] = 'Variant successfully removed.' - render :partial => 'variants', - :locals => {:product => @product}, - :layout => false - end - - protected - def load_data - @all_categories = Category.find(:all, :order=>"name") - @all_categories.unshift Category.new(:name => "") - @all_tax_treatments = TaxTreatment.find(:all, :order=>"name") - end - - private - - def build_conditions(p) - c = [] - unless @search.name.blank? - c << "products.name like :name" - p.merge! :name => "%" + @search.name + "%" - end - unless @search.sku.blank? - c << "variants.sku like :sku" - p.merge! :sku => "%" + @search.sku + "%" - end - (c.to_sentence :skip_last_comma=>true).gsub(",", " and ") - end - -end \ No newline at end of file diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb deleted file mode 100644 index e241f80474c..00000000000 --- a/app/controllers/admin/reports_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -class Admin::ReportsController < Admin::BaseController - require_role "admin" - - AVAILABLE_REPORTS = { - :sales_total => {:name => "Sales Total", :description => "Sales Total For All Orders"} - } - - def index - @reports = AVAILABLE_REPORTS - end - - def sales_total - c = build_conditions - @item_total = Order.sum(:item_total, :conditions => c) - @ship_total = Order.sum(:ship_amount, :conditions => c) - @tax_total = Order.sum(:tax_amount, :conditions => c) - @sales_total = Order.sum(:total, :conditions => c) - end - - private - - def date_conditions - return nil unless params[:search] - - @search = SearchCriteria.new(params[:search]) - - unless @search.valid? - flash.now[:error] = "Invalid search criteria. Please check your results." - return nil - end - - p = {} - c = [] - if not @search.start.blank? - c << "(orders.created_at between :start and :stop)" - p.merge! :start => @search.start.to_date - @search.stop = Date.today + 1 if @search.stop.blank? - p.merge! :stop => @search.stop.to_date + 1.day - end - - return nil if c.empty? - - {:conditions => c, :parameters => p} - end - - def build_conditions - dc = date_conditions - return nil if dc.nil? - c = dc[:conditions] - p = dc[:parameters] - [(c.to_sentence :skip_last_comma=>true).gsub(",", " and "), p] - end -end \ No newline at end of file diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb deleted file mode 100644 index 063d1fa13c4..00000000000 --- a/app/controllers/admin/users_controller.rb +++ /dev/null @@ -1,45 +0,0 @@ -# this clas was inspired (heavily) from the mephisto admin architecture - -class Admin::UsersController < Admin::BaseController - - def index - @users = User.find(:all, :page => {:size => 15, :current =>params[:page], :first => 1}) - end - - def show - @user = User.find(params[:id]) - end - - def edit - if request.post? - @user = User.find(params[:id]) - if @user.update_attributes(params[:user]) - flash[:notice] = 'User was successfully updated.' - end - else - @user = User.find(params[:id]) - end - end - - def destroy - @user = User.find(params[:id]) - @user.destroy - flash[:notice] = "User was successfully deleted." - redirect_to :action => 'index' - end - - def new - if request.post? - @user = User.new(params[:user]) - if @user.save - flash[:notice] = 'User was successfully created.' - redirect_to :action => "index" - else - flash[:error] = "Problem saving user." - end - else - @user = User.new - end - end - -end diff --git a/app/controllers/application.rb b/app/controllers/application.rb deleted file mode 100644 index 24f1680cf34..00000000000 --- a/app/controllers/application.rb +++ /dev/null @@ -1,24 +0,0 @@ -# Filters added to this controller apply to all controllers in the application. -# Likewise, all the methods added will be available for all controllers. - -class ApplicationController < ActionController::Base - before_filter :instantiate_controller_and_action_names - - # Pick a unique cookie name to distinguish our session data from others' - session :session_key => '_spree_session_id' - - # See ActionController::RequestForgeryProtection for details - # Uncomment the :secret if you're not using the cookie session store - protect_from_forgery #:secret => '55a66755bef2c41d411bd5486c001b16' - - include AuthenticatedSystem - include RoleRequirementSystem - - private - - def instantiate_controller_and_action_names - @current_action = action_name - @current_controller = controller_name - end - -end diff --git a/app/controllers/cart_controller.rb b/app/controllers/cart_controller.rb deleted file mode 100644 index b3209356b95..00000000000 --- a/app/controllers/cart_controller.rb +++ /dev/null @@ -1,44 +0,0 @@ -class CartController < Spree::BaseController - before_filter :find_cart - before_filter :store_previous_location - - def index - @cart_items = @cart.cart_items - if request.post? - @cart_items = [] - params[:item].each do |key, values| - q = values[:quantity] - if q.to_s == "0" - CartItem.destroy(key) - else - @cart_item = CartItem.find(key) - @cart_item.update_attributes(values) - @cart_items << @cart_item - end - end - end - end - - def add - variant = Variant.find(params[:id]) - item = @cart.add_variant(variant) - @cart.save - item.save - - redirect_to :action => :index - end - - def empty - @cart.cart_items.destroy_all - redirect_to :controller => :store, :action => :index - end - - private - def store_previous_location - session[:PREVIOUS_LOCATION] = nil - #ignore redirects or direct navigation cases - return if request.referer.nil? or /cart/.match request.referer - session[:PREVIOUS_LOCATION] = request.referer - end - -end diff --git a/app/controllers/checkout_controller.rb b/app/controllers/checkout_controller.rb deleted file mode 100644 index ebbfffb40c3..00000000000 --- a/app/controllers/checkout_controller.rb +++ /dev/null @@ -1,180 +0,0 @@ -class CheckoutController < Spree::BaseController - before_filter :find_order, :except => [:index, :thank_you] - - require_role 'admin', :only => :comp - - def index - find_cart - # remove any incomplete orders in the db associated with the session - session[:order_id] = nil - - if @cart.nil? || @cart.cart_items.empty? - render :template => 'checkout/empty_cart' and return - end - redirect_to :action => :addresses - end - - def addresses - @user = User.new - @states = State.find(:all, :order => 'name') - @countries = Country.find(:all) - - if request.post? - #TODO - eventually we need to grab user out of session once we support user accounts for orders - @user = User.new(params[:user]) unless params[:user].empty? - @user.password = "changeme" - @user.password_confirmation = "changeme" - #TODO - eventually you will be able to configure type of account support, for now its just anonymous - @user.login = User.generate_login - - @different_shipping = params[:different_shipping] - @bill_address = Address.new(params[:bill_address]) - #if only one country available there will be no choice (and user will post nothing) - @bill_address.country ||= Country.find(:first) - - - params[:ship_address] = params[:bill_address].dup unless params[:different_shipping] - @ship_address = Address.new(params[:ship_address]) - @ship_address.country ||= Country.find(:first) - - render :action => 'addresses' and return unless @user.valid? and @bill_address.valid? and - @ship_address.valid? - - @order.bill_address = @bill_address - @order.ship_address = @ship_address - @order.user = @user - - #if only one shipping method available there will be no choice (and user will post nothing) - @order.ship_method = params[:order][:ship_method] if params[:order] - @order.ship_method ||= 1 - - @order.ship_amount = calculate_shipping(@order) - @order.save - - redirect_to :action => 'final_confirmation' - end - end - - def final_confirmation - if request.post? - @cc = ActiveMerchant::Billing::CreditCard.new(params[:creditcard]) - @cc.first_name = @order.bill_address.firstname - @cc.last_name = @order.bill_address.lastname - - @order.number = Order.generate_order_number - @order.ip_address = request.env['REMOTE_ADDR'] - - render :action => 'final_confirmation' and return unless @cc.valid? - - # authorize creditcard - response = authorize_creditcard(@cc) - - unless response.success? - # TODO - optionally handle gateway down scenario by accepting the order and putting into a special state - msg = "Problem authorizing credit card ... \n#{response.params['error']}" - logger.error(msg) - flash[:error] = msg - render :action => 'final_confirmation' and return - end - - # Note: Create an ActiveRecord compatible object to store in our database - @order.credit_card = CreditCard.new_from_active_merchant(@cc) - @order.credit_card.txns << Txn.new( - :amount => @order.total, - :response_code => response.authorization, - :txn_type => Txn::TxnType::AUTHORIZE - ) - - @order.status = Order::Status::AUTHORIZED - finalize_order - - # send email confirmation - OrderMailer.deliver_confirm(@order) - redirect_to :action => :thank_you, :id => @order.id and return - else - @order.ship_amount = calculate_shipping(@order) - @order.tax_amount = calculate_tax(@order) - end - end - - def comp - @order.number = Order.generate_order_number - @order.ip_address = request.env['REMOTE_ADDR'] - - @order.line_items.each do |li| - li.price = 0 - end - @order.ship_amount = 0 - @order.tax_amount = 0 - - @order.order_operations << OrderOperation.new( - :operation_type => OrderOperation::OperationType::COMP, - :user => current_user - ) - - @order.status = Order::Status::PAID - finalize_order - - # send email confirmation - OrderMailer.deliver_confirm(@order) - - redirect_to :action => :thank_you, :id => @order.id and return - end - - def thank_you - @order = Order.find(params[:id]) - end - - def cvv - render :layout => false - end - - private - def find_order - id = session[:order_id] - unless id.blank? - @order = Order.find(id) - else - @order = Order.new_from_cart(find_cart) - @order.status = Order::Status::INCOMPLETE - @order.save - session[:order_id] = @order.id - @order - end - end - - def finalize_order - Order.transaction do - if @order.save - InventoryUnit.adjust(@order) - session[:order_id] = nil - # destroy cart (if applicable) - cart = find_cart - cart.destroy unless cart.new_record? - session[:cart_id] = nil - else - logger.error("problem with saving order " + @order.inspect) - redirect_to :action => :incomplete - end - end - end - - def authorize_creditcard(creditcard) - gw = payment_gateway - # ActiveMerchant is configured to use cents so we need to multiply order total by 100 - gw.authorize(@order.total * 100, creditcard, Order.gateway_options(@order)) - end - - def calculate_shipping(order) - # convert the enumeration into the title, replace spaces so we can convert to class - sm = (Order::ShipMethod.from_value order.ship_method).sub(/ /, '') - sc = sm.constantize.new - sc.shipping_cost(order) - end - - def calculate_tax(order) - # use the environment to specify the name of the tax calculator class a string - tc = TAX_CALCULATOR.constantize - tc.calc_tax(order) - end -end diff --git a/app/controllers/spree/base_controller.rb b/app/controllers/spree/base_controller.rb deleted file mode 100644 index 3ac0477d4eb..00000000000 --- a/app/controllers/spree/base_controller.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Spree::BaseController < ApplicationController - # See ActionController::RequestForgeryProtection for details - # Uncomment the :secret if you're not using the cookie session store - #protect_from_forgery :secret => '55a66755bef2c41d411bd5486c001b16' - - #include AuthenticatedSystem - #include RoleRequirementSystem - - CalendarDateSelect.format = :american - - # unique cookie name to distinguish our session data from others' - session :session_key => SESSION_KEY - - #model :order, :address - - filter_parameter_logging "password" - - def find_cart - id = session[:cart_id] - unless id.blank? - @cart = Cart.find_or_create_by_id(id) - else - @cart = Cart.create - session[:cart_id] = @cart.id - end - end - - def access_denied - if logged_in? - access_forbidden - else - store_location - redirect_to :controller => '/account', :action => 'login' - end - false - end - - def access_forbidden - render :text => 'Access Forbidden', :layout => true, :status => 401 - end - - # Instantiates the selected PAYMENT_GATEWAY and initializes with GATEWAY_OPTIONS (configured in environment.rb) - def payment_gateway - ActiveMerchant::Billing::Base.gateway_mode = :test unless RAILS_ENV == "production" - PAYMENT_GATEWAY.constantize.new(GATEWAY_OPTIONS) - end -end \ No newline at end of file diff --git a/app/controllers/store_controller.rb b/app/controllers/store_controller.rb deleted file mode 100644 index a8997968da9..00000000000 --- a/app/controllers/store_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -class StoreController < Spree::BaseController - before_filter :find_cart - - def index - list - render :action => 'list' - end - - # list products in the store - # TODO: add constraints to the find based on category, etc. - def list - @products = Product.find(:all, :page => {:start => 1, :size => 15}) - @product_cols = 3 - end - - def show - @product = Product.find(params[:id]) - end - - # AJAX method - def change_image - @product = Product.find(params[:id]) - img = Image.find(params[:image_id]) - render :partial => 'image', :locals => {:image => img} - end - -end diff --git a/app/helpers/account_helper.rb b/app/helpers/account_helper.rb deleted file mode 100644 index 1b63056541d..00000000000 --- a/app/helpers/account_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module AccountHelper -end \ No newline at end of file diff --git a/app/helpers/admin/base_helper.rb b/app/helpers/admin/base_helper.rb deleted file mode 100644 index feae895a1b5..00000000000 --- a/app/helpers/admin/base_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Admin::BaseHelper - def breadcrumb_nav category - ancestor(category) + link_to(category.name, :id => category) - end - - private - def ancestor(category, hide_last = false) - if category.parent - ancestor(category.parent) + link_to(category.parent.name, :id => category.parent) + (hide_last ? '' : ' > ') - else - "" - end - end -end diff --git a/app/helpers/admin/categories_helper.rb b/app/helpers/admin/categories_helper.rb deleted file mode 100644 index 90b8e749069..00000000000 --- a/app/helpers/admin/categories_helper.rb +++ /dev/null @@ -1,3 +0,0 @@ -module Admin::CategoriesHelper - -end diff --git a/app/helpers/admin/option_values_helper.rb b/app/helpers/admin/option_values_helper.rb deleted file mode 100644 index 885e75bcf7e..00000000000 --- a/app/helpers/admin/option_values_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module OptionValuesHelper -end diff --git a/app/helpers/admin/orders_helper.rb b/app/helpers/admin/orders_helper.rb deleted file mode 100644 index 2ae59ac8a45..00000000000 --- a/app/helpers/admin/orders_helper.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Admin::OrdersHelper - - # return the list of possible actions for the order based on its current state - def action_links(order) - state = Order::Status.from_value(order.status) - return [] if state.nil? - state = state.gsub(' ', '_').downcase.to_sym - AVAILABLE_OPERATIONS[state] - end - -end diff --git a/app/helpers/admin/overview_helper.rb b/app/helpers/admin/overview_helper.rb deleted file mode 100644 index 981b542026f..00000000000 --- a/app/helpers/admin/overview_helper.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Admin::OverviewHelper - def render_ordes(orders, later=false) - text = [] - if orders.any? - # TODO: handle Order objects, create partials for this - # orders.each_with_index {|order, i| text << render(:partial => "#{order.mode}_event", :locals => {:order => order, :shaded => (i % 2 > 0), :later => later}) } - else - text << %w(
  • No orders
  • ) - end - %() - end -end diff --git a/app/helpers/admin/products_helper.rb b/app/helpers/admin/products_helper.rb deleted file mode 100644 index c77539c0f5b..00000000000 --- a/app/helpers/admin/products_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Admin::ProductsHelper - def option_type_select(so) - select(:new_variant, - so.option_type.presentation, - so.option_type.option_values.collect {|ov| [ ov.presentation, ov.id ] }) - end -end \ No newline at end of file diff --git a/app/helpers/admin/users_helper.rb b/app/helpers/admin/users_helper.rb deleted file mode 100644 index 5995c2aa82d..00000000000 --- a/app/helpers/admin/users_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Admin::UsersHelper -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb deleted file mode 100644 index 0eb301ed91d..00000000000 --- a/app/helpers/application_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# Methods added to this helper will be available to all templates in the application. -module ApplicationHelper - - # helper to determine if its appropriate to show the store menu - def store_menu? - return true unless %w{thank_you}.include? @current_action - false - end -end diff --git a/app/helpers/cart_helper.rb b/app/helpers/cart_helper.rb deleted file mode 100644 index ebf5f20d9b7..00000000000 --- a/app/helpers/cart_helper.rb +++ /dev/null @@ -1,6 +0,0 @@ -module CartHelper - def previous_location - session[:PREVIOUS_LOCATION] - end -end - diff --git a/app/helpers/checkout_helper.rb b/app/helpers/checkout_helper.rb deleted file mode 100644 index 72b5360b8cc..00000000000 --- a/app/helpers/checkout_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -module CheckoutHelper - CREDIT_CARD_TYPES = [ - ["Visa", "visa"], - ["Master Card", "master"], - ["Discover", "discover"], - ["American Express", "american_express"] - ].freeze - - #for some reason i had to define a method ... apparently constants are not mixed in - #in the view helper (not sure but that's my guess) - def credit_card_types - CREDIT_CARD_TYPES - end -end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb deleted file mode 100644 index 36fea2e61d8..00000000000 --- a/app/helpers/search_helper.rb +++ /dev/null @@ -1,10 +0,0 @@ -module SearchHelper - def search_options - options = {} - return options if params[:search].nil? - params[:search].each do |name, value| - options["search[#{name}]"] = value - end - options - end -end \ No newline at end of file diff --git a/app/helpers/spree/base_helper.rb b/app/helpers/spree/base_helper.rb deleted file mode 100644 index 955f7a306a8..00000000000 --- a/app/helpers/spree/base_helper.rb +++ /dev/null @@ -1,106 +0,0 @@ -module Spree::BaseHelper - require 'paginating_find' - - def stylesheets - stylesheets = [stylesheet_link_tag("spree"), stylesheet_link_tag("application")] - ["#{controller.controller_name}/_controller", "#{controller.controller_name}/#{:action_name}"].each do |stylesheet| - if File.exists? "#{RAILS_ROOT}/public/stylesheets/#{stylesheet}.css" - stylesheets << stylesheet_link_tag(stylesheet) - # TODO - consider bringing this back to use with stylesheets in the extension - #else - # stylesheets << stylesheet_link_tag(stylesheet, :plugin=>"spree") if File.exists? "#{RAILS_ROOT}/public/plugin_assets/spree/stylesheets/#{stylesheet}.css" - end - end - stylesheets.compact.join("\n") - end - - def windowed_pagination_links(pagingEnum, options) - link_to_current_page = options[:link_to_current_page] - always_show_anchors = options[:always_show_anchors] - padding = options[:window_size] - - current_page = pagingEnum.page - html = '' - - #Calculate the window start and end pages - padding = padding < 0 ? 0 : padding - first = pagingEnum.page_exists?(current_page - padding) ? current_page - padding : 1 - last = pagingEnum.page_exists?(current_page + padding) ? current_page + padding : pagingEnum.last_page - - # Print start page if anchors are enabled - html << yield(1) if always_show_anchors and not first == 1 - - # Print window pages - first.upto(last) do |page| - (current_page == page && !link_to_current_page) ? html << page : html << yield(page) - end - - # Print end page if anchors are enabled - html << yield(pagingEnum.last_page) if always_show_anchors and not last == pagingEnum.last_page - html - end - - def add_product_link(text, product) - link_to_remote text, {:url => {:controller => "cart", - :action => "add", :id => product}}, - {:title => "Add to Cart", - :href => url_for( :controller => "cart", - :action => "add", :id => product)} - end - - def remove_product_link(text, product) - link_to_remote text, {:url => {:controller => "cart", - :action => "remove", - :id => product}}, - {:title => "Remove item", - :href => url_for( :controller => "cart", - :action => "remove", :id => product)} - end - - def todays_short_date - utc_to_local(Time.now.utc).to_ordinalized_s(:stub) - end - - def yesterdays_short_date - utc_to_local(Time.now.utc.yesterday).to_ordinalized_s(:stub) - end - - - # human readable list of variant options - def variant_options(v) - list = [] - v.option_values.each do |ov| - list << ov.option_type.presentation + ": " + ov.presentation - end - list.to_sentence({:connector => ","}) - end - - def mini_image(product) - if product.images.empty? - # TODO - show image not available - else - image_tag product.images.first.public_filename(:mini) - end - end - - def small_image(product) - if product.images.empty? - # TODO - show image not available - else - image_tag product.images.first.public_filename(:small) - end - end - - def product_image(product) - if product.images.empty? - # TODO - show image not available - else - image_tag product.images.first.public_filename(:product) - end - end - - # amount of on hand inventory for the specified variant - def on_hand(variant) - variant.inventory(InventoryUnit::Status::ON_HAND) - end -end \ No newline at end of file diff --git a/app/helpers/store_helper.rb b/app/helpers/store_helper.rb deleted file mode 100644 index 17aa2c27367..00000000000 --- a/app/helpers/store_helper.rb +++ /dev/null @@ -1,3 +0,0 @@ -module StoreHelper - -end \ No newline at end of file diff --git a/app/models/address.rb b/app/models/address.rb deleted file mode 100644 index 4b7f157a5b9..00000000000 --- a/app/models/address.rb +++ /dev/null @@ -1,17 +0,0 @@ -class Address < ActiveRecord::Base - belongs_to :country - belongs_to :state - - validates_presence_of :firstname - validates_presence_of :lastname - validates_presence_of :address1 - validates_presence_of :city - validates_presence_of :state - validates_presence_of :zipcode - validates_presence_of :country - validates_presence_of :phone - - def full_name - self.firstname + " " + self.lastname - end -end diff --git a/app/models/cart.rb b/app/models/cart.rb deleted file mode 100644 index 511d6c69802..00000000000 --- a/app/models/cart.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Cart < ActiveRecord::Base - has_many :cart_items, :dependent => :destroy do - def in_cart(variant) - find :first, :conditions => ['variant_id = ?', variant.id] - end - end - has_many :products, :through => :cart_items - - def total - cart_items.inject(0) {|sum, n| n.price * n.quantity + sum} - end - - def add_variant(variant, quantity=1) - current_item = cart_items.in_cart(variant) - if current_item - current_item.increment_quantity unless quantity > 1 - current_item.quantity = (current_item.quantity + quantity) if quantity > 1 - else - current_item = CartItem.new(:quantity => quantity, :variant => variant) - cart_items << current_item - end - current_item - end -end diff --git a/app/models/cart_item.rb b/app/models/cart_item.rb deleted file mode 100644 index 2239acaed48..00000000000 --- a/app/models/cart_item.rb +++ /dev/null @@ -1,29 +0,0 @@ -class CartItem < ActiveRecord::Base - belongs_to :variant - belongs_to :cart - - validates_presence_of :variant, :quantity - validates_numericality_of :quantity, :only_integer => true, :message => "must be an integer" - - def validate - unless quantity && quantity >= 0 - errors.add(:quantity, "must be a positive value") - end - unless quantity <= 100000 - errors.add(:quantity, "is too large") - end - end - - def increment_quantity - self.quantity += 1 - end - - def decrement_quantity - self.quantity -= 1 - end - - def price - self.variant.product.price - end - -end diff --git a/app/models/category.rb b/app/models/category.rb deleted file mode 100644 index dbbaa97c9fb..00000000000 --- a/app/models/category.rb +++ /dev/null @@ -1,50 +0,0 @@ -class Category < ActiveRecord::Base - has_many :products - acts_as_list :scope => :parent_id - acts_as_tree :order => :position - has_and_belongs_to_many :tax_treatments - validates_presence_of :name - - def ancestors_name - if parent - parent.ancestors_name + parent.name + ':' - else - "" - end - end - - def long_name - ancestors_name + name - end - - def before_save - self.parent = nil if parent == self - end - - # Serious Ruby hacking going on here. We alias the original method for the association as added by - # ActiveRecord and then override it so we can return the parent category's treatments if they are present. - alias :ar_tax_treatments :tax_treatments - def tax_treatments - tt = ar_tax_treatments - return tt unless tt.empty? - if self.parent and not self.parent.tax_treatments.empty? - # return a frozen copy of the parent category's treatments - return Array.new(self.parent.tax_treatments).freeze - else - # return category tax treatments - return tt - end - end - - # category may have a new parent so we should be sure to remove any db records associated with - # the previous parent-child relationship - def before_update - return if self.parent.nil? - return if self.tax_treatments.frozen? # tax treatments were inherited from previous parent - leave them alone - #unless self.parent.variations.empty? # new parent has no variations to inherit - leave the current ones alone - # self.ar_variations.each do |v| - # v.destroy - # end - #end - end -end \ No newline at end of file diff --git a/app/models/country.rb b/app/models/country.rb deleted file mode 100644 index 80ccfad7e6f..00000000000 --- a/app/models/country.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Country < ActiveRecord::Base - has_many :states -end \ No newline at end of file diff --git a/app/models/credit_card.rb b/app/models/credit_card.rb deleted file mode 100644 index d758567f070..00000000000 --- a/app/models/credit_card.rb +++ /dev/null @@ -1,24 +0,0 @@ -# ActiveMerchant supplies a credit card class but it is not implemented as an ActiveRecord object in order to -# discourage storing the information in the database. It is, however, both safe and desirable to store this -# information provided the necessary precautions are taken. It is safe if you use PGP encryption to encrypt -# the number and verification code. The private key, however, must be stored securely on a separate physical machine. -# It is desirable, because your gateway could go down for several minutes or even hours and you may want to run -# these transactions later when the gateway becomes available again. -class CreditCard < ActiveRecord::Base - has_many :txns - belongs_to :order - - # creates a new instance of CreditCard using the active merchant version - def self.new_from_active_merchant(cc) - card = self.new - card.number = cc.number - card.cc_type = cc.type - card.display_number = cc.display_number - card.verification_value = cc.verification_value - card.month = cc.month - card.year = cc.year - card.first_name = cc.first_name - card.last_name = cc.last_name - card - end -end \ No newline at end of file diff --git a/app/models/image.rb b/app/models/image.rb deleted file mode 100644 index cf417fd99f2..00000000000 --- a/app/models/image.rb +++ /dev/null @@ -1,13 +0,0 @@ -class Image < ActiveRecord::Base - belongs_to :viewable, :polymorphic => true - acts_as_list :scope => :parent - has_attachment :content_type => :image, - :max_size => 500.kilobyte, - :resize_to => [360,360], - :thumbnails => {:product => [240,240], :small => [100,100], :mini => [48,48]}, - :path_prefix => 'public/images/products', - :storage => :file_system, - :processor => :MiniMagick - - validates_as_attachment -end \ No newline at end of file diff --git a/app/models/inventory_level.rb b/app/models/inventory_level.rb deleted file mode 100644 index 04fa760cc64..00000000000 --- a/app/models/inventory_level.rb +++ /dev/null @@ -1,19 +0,0 @@ -# Tableless model based on a forum post by Rick Olson -class InventoryLevel < ActiveRecord::Base - #Inventory level does not need to be stored in the database so these two methods will spoof the column stuff - def self.columns() @columns ||= []; end - def self.column(name, sql_type = nil, default = nil, null = true) - columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null) - end - - #column :current, :integer - column :adjustment, :integer - column :on_hand, :integer - - #validates_numericality_of :adjustment, :only_integer => true - #def validate - # to do perform validation - #errors.add(:start, "Must specify a start date") and return if start.blank? and not stop.blank? - #throw "Invalid Inventory Quantity" - #end -end \ No newline at end of file diff --git a/app/models/inventory_unit.rb b/app/models/inventory_unit.rb deleted file mode 100644 index 06f22cf4a41..00000000000 --- a/app/models/inventory_unit.rb +++ /dev/null @@ -1,49 +0,0 @@ -class InventoryUnit < ActiveRecord::Base - belongs_to :variant - belongs_to :order - validates_presence_of :status - - enumerable_constant :status, {:constants => INVENTORY_STATES, :no_validation => true} - - # destory the specified number of on hand inventory units - def self.destroy_on_hand(variant, quantity) - inventory = self.find_by_status(variant, quantity, InventoryUnit::Status::ON_HAND) - inventory.each do |unit| - unit.destroy - end - end - - # destory the specified number of on hand inventory units - def self.create_on_hand(variant, quantity) - quantity.times do - self.create(:variant => variant, :status => InventoryUnit::Status::ON_HAND) - end - end - - # adjust inventory status for the line items in the order - def self.adjust(order) - order.line_items.each do |line_item| - variant = line_item.variant - quantity = line_item.quantity - # retrieve the requested number of on hand units (or as many as possible) - note: optimistic locking used here - on_hand = self.find_by_status(variant, quantity, InventoryUnit::Status::ON_HAND) - # mark all of these units as sold and associate them with this order - on_hand.each do |unit| - unit.update_attributes(:status => InventoryUnit::Status::SOLD, :order => order) - end - # right now we always allow back ordering - backorder = quantity - on_hand.size - backorder.times do - self.create(:variant => variant, :status => InventoryUnit::Status::BACK_ORDERED, :order => order) - end - end - end - - # find the specified quantity of units with the specified status - def self.find_by_status(variant, quantity, status) - variant.inventory_units.find(:all, - :conditions => ['status = ? ', status], - :limit => quantity) - end - -end \ No newline at end of file diff --git a/app/models/line_item.rb b/app/models/line_item.rb deleted file mode 100644 index 390f781b658..00000000000 --- a/app/models/line_item.rb +++ /dev/null @@ -1,22 +0,0 @@ -class LineItem < ActiveRecord::Base - belongs_to :order - belongs_to :variant - - validates_presence_of :variant - validates_numericality_of :quantity - validates_numericality_of :price - - def self.from_cart_item(cart_item) - line_item = self.new - line_item.quantity = cart_item.quantity - line_item.price = cart_item.price - line_item.variant = cart_item.variant - line_item - end - - def total - self.price * self.quantity - end - -end - diff --git a/app/models/option_type.rb b/app/models/option_type.rb deleted file mode 100644 index f4e6a389510..00000000000 --- a/app/models/option_type.rb +++ /dev/null @@ -1,4 +0,0 @@ -class OptionType < ActiveRecord::Base - has_many :option_values, :order => :position, :dependent => :destroy - validates_presence_of :name -end \ No newline at end of file diff --git a/app/models/option_value.rb b/app/models/option_value.rb deleted file mode 100644 index 857b8da9a12..00000000000 --- a/app/models/option_value.rb +++ /dev/null @@ -1,5 +0,0 @@ -class OptionValue < ActiveRecord::Base - belongs_to :option_type - acts_as_list :scope => :option_type - has_and_belongs_to_many :variants -end diff --git a/app/models/order.rb b/app/models/order.rb deleted file mode 100644 index 62caa63a646..00000000000 --- a/app/models/order.rb +++ /dev/null @@ -1,88 +0,0 @@ -class Order < ActiveRecord::Base - has_many :line_items - has_many :inventory_units - has_many :order_operations - has_one :credit_card - belongs_to :user - belongs_to :bill_address, :class_name => "Address", :foreign_key => :bill_address_id - belongs_to :ship_address, :class_name => "Address", :foreign_key => :ship_address_id - - enumerable_constant :status, :constants => ORDER_STATES - enumerable_constant :ship_method, {:constants => SHIPPING_METHODS, :no_validation => true} - - #TODO - validate presence of user once we have the means to add one through controller - validates_presence_of :line_items - validates_associated :line_items, :message => "are not valid" - validates_numericality_of :tax_amount - validates_numericality_of :ship_amount - validates_numericality_of :item_total - validates_numericality_of :total - - def self.new_from_cart(cart) - return nil if cart.cart_items.empty? - order = self.new - order.line_items = cart.cart_items.map do |item| - LineItem.from_cart_item(item) - end - order - end - - def self.generate_order_number - record = true - while record - random = Array.new(9){rand(9)}.join - record = find(:first, :conditions => ["number = ?", random]) - end - return random - end - - # total of line items (no tax or shipping inc.) - def item_total - tot = 0 - self.line_items.each do |li| - tot += li.total - end - self.item_total = tot - end - - def total - self.total = self.item_total + self.ship_amount + self.tax_amount - end - - - # Generate standard options used by ActiveMerchant gateway for authorize, capture, etc. - def self.gateway_options(order) - billing_address = {:name => order.bill_address.full_name, - :address1 => order.bill_address.address1, - :address2 => order.bill_address.address2, - :city => order.bill_address.city, - :state => order.bill_address.state.abbr, - :zip => order.bill_address.zipcode, - :country => order.bill_address.country.name, - :phone => order.bill_address.phone} - shipping_address = {:name => order.ship_address.full_name, - :address1 => order.ship_address.address1, - :address2 => order.ship_address.address2, - :city => order.ship_address.city, - :state => order.ship_address.state.abbr, - :zip => order.ship_address.zipcode, - :country => order.ship_address.country.name, - :phone => order.ship_address.phone} - options = {:billing_address => billing_address, :shipping_address => shipping_address} - options.merge(self.minimal_gateway_options(order)) - end - - # Generates a minimal set of gateway options. There appears to be some issues with passing in - # a billing address when authorizing/voiding a previously captured transaction. So omits these - # options in this case since they aren't necessary. - def self.minimal_gateway_options(order) - {:email => order.user.email, - :customer => order.user.login, - :ip => order.ip_address, - :order_id => order.number, - :shipping => order.ship_amount * 100, - :tax => order.tax_amount * 100, - :subtotal => order.item_total * 100} - end - -end diff --git a/app/models/order_mailer.rb b/app/models/order_mailer.rb deleted file mode 100644 index 8019e53cf1d..00000000000 --- a/app/models/order_mailer.rb +++ /dev/null @@ -1,22 +0,0 @@ -class OrderMailer < ActionMailer::Base - helper "spree/base" - - def confirm(order, resend = false) - @subject = (resend ? "[RESEND] " : "") - @subject += 'Order Confirmation #' + order.number - @body = {"order" => order} - @recipients = order.user.email - @from = ORDER_FROM - @bcc = ORDER_BCC unless ORDER_BCC.empty? or resend - @sent_on = Time.now - end - - def cancel(order) - @subject = '[CANCEL] Order Confirmation #' + order.number - @body = {"order" => order} - @recipients = order.user.email - @from = ORDER_FROM - @bcc = ORDER_BCC unless ORDER_BCC.empty? - @sent_on = Time.now - end -end diff --git a/app/models/order_operation.rb b/app/models/order_operation.rb deleted file mode 100644 index e10dc6229fe..00000000000 --- a/app/models/order_operation.rb +++ /dev/null @@ -1,7 +0,0 @@ -class OrderOperation < ActiveRecord::Base - belongs_to :user - belongs_to :order - - enumerable_constant :operation_type, {:constants => ORDER_OPERATIONS, :no_validation => true} - -end \ No newline at end of file diff --git a/app/models/payment.rb b/app/models/payment.rb deleted file mode 100644 index 562384b12dd..00000000000 --- a/app/models/payment.rb +++ /dev/null @@ -1,49 +0,0 @@ -class Payment < ActiveRecord::Base - - def self.columns() @columns ||= []; end - def self.column(name, sql_type = nil, default = nil, null = true) - columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null) - end - - class CreditCard < Payment - - column :cc_number, :string - column :cc_exp_year, :string - column :cc_exp_month, :string - column :cvv, :string - - - def cvv_valid? - self.cvv =~ Format::CVV_REGEX ? true : false - end - - # This is one way to do a custom validation. - def self.validates_credit_card(*attr_names) - validates_each(attr_names) do |record, attr_name, value| - unless passes_mod_10?(value.to_s) - record.errors.add(attr_name, "Credit card expiration month is invalid") - end - end - end - - def self.passes_mod_10?(number) - return false unless number.to_s.length >= 13 - sum = 0 - for i in 0..number.length - weight = number[-1 * (i + 2), 1].to_i * (2 - (i % 2)) - sum += (weight < 10) ? weight : weight - 9 - end - (number[-1,1].to_i == (10 - sum % 10) % 10) - end - - - validates_inclusion_of :cc_exp_year, :in=>%w( 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 ), :message=>"Credit card expiration year is invalid" - validates_inclusion_of :cc_exp_month, :in=>%w( 01 02 03 04 05 06 07 08 09 10 11 12 ), :message=>"Credit card expiration month is invalid" - validates_credit_card :cc_number - - end - - class Check < Payment - end - -end diff --git a/app/models/product.rb b/app/models/product.rb deleted file mode 100644 index 1e6a650667b..00000000000 --- a/app/models/product.rb +++ /dev/null @@ -1,64 +0,0 @@ -class Product < ActiveRecord::Base - has_many :product_option_types, :dependent => :destroy - has_many :option_types, :through => :product_option_types - has_many :variants, :dependent => :destroy - belongs_to :category - has_and_belongs_to_many :tax_treatments - has_many :images, :as => :viewable, :order => :position, :dependent => :destroy - - validates_presence_of :name - validates_presence_of :description - validates_presence_of :price - before_create :empty_variant - - alias :selected_options :product_option_types - - # checks is there are any meaningful variants (ie. variants with at least one option value) - def variants? - self.variants.each do |v| - return true unless v.option_values.empty? - end - false - end - - # special method that returns the single empty variant (but only if there are no meaningful variants) - def variant - return nil if variants? - variants.first - end - - # if product has a new category then we may need to delete tax_treatments associated with the - # previous category - def before_update - return if self.category.nil? - ar_tax_treatments.clear unless self.category.tax_treatments.empty? - end - - def apply_tax_treatment?(id) - return true if self.tax_treatments.any? {|tt| tt.id == id} - return self.category.tax_treatments.any? {|tt| tt.id == id} unless self.category.nil? - end - - # Serious Ruby hacking going on here. We alias the original method for the association as added by - # ActiveRecord and then override it so we can return the categories treatments if they are present. - alias :ar_tax_treatments :tax_treatments - def tax_treatments - tt = ar_tax_treatments - return tt unless tt.empty? - return tt if self.category.nil? - if self.category.tax_treatments.empty? - # return empty array (does not need to be frozen since category has none) - return tt - else - # return a frozen copy of the category tax treatments - return Array.new(self.category.tax_treatments).freeze - end - end - - private - - # all products must have an "empty variant" (this variant will be ignored if meaningful ones are added later) - def empty_variant - self.variants << Variant.new - end -end \ No newline at end of file diff --git a/app/models/product_option_type.rb b/app/models/product_option_type.rb deleted file mode 100644 index 55f1d90bd8c..00000000000 --- a/app/models/product_option_type.rb +++ /dev/null @@ -1,5 +0,0 @@ -class ProductOptionType < ActiveRecord::Base - belongs_to :product - belongs_to :option_type - acts_as_list :scope => :product -end \ No newline at end of file diff --git a/app/models/role.rb b/app/models/role.rb deleted file mode 100644 index b1129bffd78..00000000000 --- a/app/models/role.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Role < ActiveRecord::Base - -end \ No newline at end of file diff --git a/app/models/search_criteria.rb b/app/models/search_criteria.rb deleted file mode 100644 index 389ac184318..00000000000 --- a/app/models/search_criteria.rb +++ /dev/null @@ -1,27 +0,0 @@ -# Tableless model based on a forum post by Rick Olson -class SearchCriteria < ActiveRecord::Base - #Search criteria does not need to be stored in the database so these two methods will spoof the column stuff - def self.columns() @columns ||= []; end - def self.column(name, sql_type = nil, default = nil, null = true) - columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null) - end - - column :start, :string - column :stop, :string - column :order_num, :string - column :status, :integer - column :customer, :string - column :name, :string - column :sku, :string - - def validate - date_pattern = /^(0[1-9]|1[012])[\/][0-9]{2}[\/](19|20)[0-9]{2}$/ - errors.add(:start, "Must specify a start date") and return if start.blank? and not stop.blank? - errors.add(:start, "Date must be formatted MM/DD/YYYY") unless start.blank? or date_pattern.match start.to_s - errors.add(:stop, "Date must be formatted MM/DD/YYYY") unless stop.blank? or date_pattern.match stop.to_s - unless stop.blank? - errors.add(:stop, "Stop date must be after start date") if DateTime.parse(stop) < DateTime.parse(start) - end - end - -end \ No newline at end of file diff --git a/app/models/state.rb b/app/models/state.rb deleted file mode 100644 index 56189f1ce46..00000000000 --- a/app/models/state.rb +++ /dev/null @@ -1,3 +0,0 @@ -class State < ActiveRecord::Base - belongs_to :country -end \ No newline at end of file diff --git a/app/models/tag.rb b/app/models/tag.rb deleted file mode 100644 index 1e3fc51e61b..00000000000 --- a/app/models/tag.rb +++ /dev/null @@ -1,37 +0,0 @@ - -# The Tag model. This model is automatically generated and added to your app if you run the tagging generator included with has_many_polymorphs. - -class Tag < ActiveRecord::Base - - DELIMITER = " " # Controls how to split and join tagnames from strings. You may need to change the validates_format_of parameters if you change this. - - # If database speed becomes an issue, you could remove these validations and rescue the ActiveRecord database constraint errors instead. - validates_presence_of :name - validates_uniqueness_of :name, :case_sensitive => false - - # Change this validation if you need more complex tag names. - validates_format_of :name, :with => /^[a-zA-Z0-9\_\-]+$/, :message => "can not contain special characters" - - # Set up the polymorphic relationship. - has_many_polymorphs :taggables, - :from => [:products], - :through => :taggings, - :dependent => :destroy, - :skip_duplicates => false, - :parent_extend => proc { - # Defined on the taggable models, not on Tag itself. Return the tagnames associated with this record as a string. - def to_s - self.map(&:name).sort.join(Tag::DELIMITER) - end - } - - # Callback to strip extra spaces from the tagname before saving it. If you allow tags to be renamed later, you might want to use the before_save callback instead. - def before_create - self.name = name.downcase.strip.squeeze(" ") - end - - # Tag::Error class. Raised by ActiveRecord::Base::TaggingExtensions if something goes wrong. - class Error < StandardError - end - -end diff --git a/app/models/tagging.rb b/app/models/tagging.rb deleted file mode 100644 index 321341c7b7f..00000000000 --- a/app/models/tagging.rb +++ /dev/null @@ -1,16 +0,0 @@ - -# The Tagging join model. This model is automatically generated and added to your app if you run the tagging generator included with has_many_polymorphs. - -class Tagging < ActiveRecord::Base - - belongs_to :tag - belongs_to :taggable, :polymorphic => true - - # If you also need to use acts_as_list, you will have to manage the tagging positions manually by creating decorated join records when you associate Tags with taggables. - # acts_as_list :scope => :taggable - - # This callback makes sure that an orphaned Tag is deleted if it no longer tags anything. - def before_destroy - tag.destroy_without_callbacks if tag and tag.taggings.count == 1 - end -end diff --git a/app/models/tax_treatment.rb b/app/models/tax_treatment.rb deleted file mode 100644 index 94bc9664e31..00000000000 --- a/app/models/tax_treatment.rb +++ /dev/null @@ -1,2 +0,0 @@ -class TaxTreatment < ActiveRecord::Base -end diff --git a/app/models/txn.rb b/app/models/txn.rb deleted file mode 100644 index 1414d7ff16d..00000000000 --- a/app/models/txn.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Txn < ActiveRecord::Base - belongs_to :credit_card - validates_numericality_of :amount - #validates_presence_of :cc_number - - enumerable_constant :txn_type, :constants => TXN_TYPES -end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb deleted file mode 100644 index 50a6a6d975e..00000000000 --- a/app/models/user.rb +++ /dev/null @@ -1,102 +0,0 @@ -require 'digest/sha1' -class User < ActiveRecord::Base - # Virtual attribute for the unencrypted password - attr_accessor :password - - validates_presence_of :login, :email - validates_format_of :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :message => 'email must be valid' - validates_presence_of :password, :if => :password_required? - validates_presence_of :password_confirmation, :if => :password_required? - validates_length_of :password, :within => 4..40, :if => :password_required? - validates_confirmation_of :password, :if => :password_required? - validates_length_of :login, :within => 3..40 - validates_length_of :email, :within => 3..100 - validates_uniqueness_of :login, :case_sensitive => false - before_save :encrypt_password - - has_many :orders - has_many :ship_addresses, :class_name => "Address", :as => :addressable - has_many :bill_addresses, :class_name => "Address", :as => :addressable - has_and_belongs_to_many :roles - - # prevents a user from submitting a crafted form that bypasses activation - # anything else you want your user to change should be added here. - attr_accessible :login, :email, :password, :password_confirmation - - # has_role? simply needs to return true or false whether a user has a role or not. - def has_role?(role_in_question) - @_list ||= self.roles.collect(&:name) - return true if @_list.include?("admin") - (@_list.include?(role_in_question.to_s) ) - end - - # Authenticates a user by their login name and unencrypted password. Returns the user or nil. - def self.authenticate(login, password) - u = find_by_login(login) # need to get the salt - u && u.authenticated?(password) ? u : nil - end - - # Encrypts some data with the salt. - def self.encrypt(password, salt) - Digest::SHA1.hexdigest("--#{salt}--#{password}--") - end - - # Encrypts the password with the user salt - def encrypt(password) - self.class.encrypt(password, salt) - end - - def authenticated?(password) - crypted_password == encrypt(password) - end - - def remember_token? - exp_time = ParseDate.parsedate(remember_token_expires_at) - remember_token_expires_at && Time.now.utc < Time.gm(*exp_time) - end - - # These create and unset the fields required for remembering users between browser closes - def remember_me - remember_me_for 2.weeks - end - - def remember_me_for(time) - remember_me_until time.from_now.utc - end - - def remember_me_until(time) - self.remember_token_expires_at = time - self.remember_token = encrypt("#{email}--#{remember_token_expires_at}") - save(false) - end - - def forget_me - self.remember_token_expires_at = nil - self.remember_token = nil - save(false) - end - - # for anonymous customer support - def self.generate_login - record = true - while record - login = "anon_" + Array.new(6){rand(6)}.join - record = find(:first, :conditions => ["login = ?", login]) - end - return login - end - - protected - # before filter - def encrypt_password - return if password.blank? - self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record? - self.crypted_password = encrypt(password) - end - - def password_required? - crypted_password.blank? || !password.blank? - end - - -end \ No newline at end of file diff --git a/app/models/variant.rb b/app/models/variant.rb deleted file mode 100644 index 30ce0402ad1..00000000000 --- a/app/models/variant.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Variant < ActiveRecord::Base - belongs_to :product - has_many :inventory_units - has_and_belongs_to_many :option_values - validates_presence_of :product - - # gives the inventory count for variants with the specified inventory status - def inventory(status) - InventoryUnit.count(:conditions => "status = #{status} AND variant_id = #{self.id}", :joins => "LEFT JOIN variants on variants.id = variant_id") - end -end diff --git a/app/views/account/index.rhtml b/app/views/account/index.rhtml deleted file mode 100644 index d460c44c517..00000000000 --- a/app/views/account/index.rhtml +++ /dev/null @@ -1,3 +0,0 @@ -

    Hello User

    - -[TODO] - Add content here \ No newline at end of file diff --git a/app/views/account/login.rhtml b/app/views/account/login.rhtml deleted file mode 100644 index 06786bec146..00000000000 --- a/app/views/account/login.rhtml +++ /dev/null @@ -1,12 +0,0 @@ -<% form_tag do -%> -


    -<%= text_field_tag 'login' %>

    - -


    -<%= password_field_tag 'password' %>

    - -

    -<%= check_box_tag 'remember_me' %>

    - -

    <%= submit_tag 'Log in' %>

    -<% end -%> diff --git a/app/views/account/signup.rhtml b/app/views/account/signup.rhtml deleted file mode 100644 index c0012a73d3c..00000000000 --- a/app/views/account/signup.rhtml +++ /dev/null @@ -1,16 +0,0 @@ -<%= error_messages_for :user %> -<% form_for :user do |f| -%> -


    -<%= f.text_field :login %>

    - -


    -<%= f.text_field :email %>

    - -


    -<%= f.password_field :password %>

    - -


    -<%= f.password_field :password_confirmation %>

    - -

    <%= submit_tag 'Sign up' %>

    -<% end -%> diff --git a/app/views/admin/categories/_form.rhtml b/app/views/admin/categories/_form.rhtml deleted file mode 100644 index 5141e02f9d8..00000000000 --- a/app/views/admin/categories/_form.rhtml +++ /dev/null @@ -1,20 +0,0 @@ -<%= error_messages_for 'category' %> - - -


    -<%= text_field 'category', 'name' %>

    - -


    -<%= collection_select :category, :parent_id, @all_categories, :id, :long_name, {}, {"style" => "width:250px"} %>

    - -<%= observe_field :category_parent_id, - :update => 'treatmentWrapper', - :url => {:action => :tax_treatments}, - :with => "'category_id=#{@category.id}&parent_id=' + encodeURIComponent(value)", - :on => 'changed' -%> - -
    - <%= render :partial => 'shared/tax_treatments', :locals => {:tax_treatments => @all_tax_treatments, :selected_treatments => @category.tax_treatments} -%> -
    - - diff --git a/app/views/admin/categories/edit.rhtml b/app/views/admin/categories/edit.rhtml deleted file mode 100644 index 70c25cb39f8..00000000000 --- a/app/views/admin/categories/edit.rhtml +++ /dev/null @@ -1,9 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -

    Editing category

    - -<% form_tag :action => 'update', :id => @category do %> - <%= render :partial => 'form' %> -
    - <%= submit_tag 'Update'%> or <%= link_to 'Cancel', :action => 'index' %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/categories/list.rhtml b/app/views/admin/categories/list.rhtml deleted file mode 100644 index 2ae554a4d38..00000000000 --- a/app/views/admin/categories/list.rhtml +++ /dev/null @@ -1,47 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -<%= link_to "Add Category", :controller => 'categories', :action => 'new' -%> -

    - -

    Listing categories

    - - - - - - - - - - - -<% for category in @categories %> - - - - -<% end %> -
    NameAction
    - <% if category.children.empty? %> - <%= category.name %> - <% else %> - <%= link_to category.name, :id => category%> - <% end %> - - <%= link_to 'Edit', :action => 'edit', :id => category %> | - <%= link_to 'Delete', { :action => 'destroy', :id => category }, :confirm => 'Are you sure you want to delete this category?' %> -
    -<%= render :partial => 'shared/paginate', :locals => {:collection => @categories, :options => {}} -%> \ No newline at end of file diff --git a/app/views/admin/categories/new.rhtml b/app/views/admin/categories/new.rhtml deleted file mode 100644 index f0f5b5a1636..00000000000 --- a/app/views/admin/categories/new.rhtml +++ /dev/null @@ -1,8 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -

    New category

    - -<% form_tag :action => 'new' do %> - <%= render :partial => 'form' %> - <%= submit_tag "Create" %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/images/new.html.erb b/app/views/admin/images/new.html.erb deleted file mode 100644 index defe98bc3e5..00000000000 --- a/app/views/admin/images/new.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -

    New Image

    -<% form_for(:image, :url => { :controller => 'images', :action => "new" }, :html => { :multipart => true }) do |form| %> - - - - - -
    Filename:<%= form.file_field :uploaded_data %>
    - <% end %> - \ No newline at end of file diff --git a/app/views/admin/inventory_units/adjust.html.erb b/app/views/admin/inventory_units/adjust.html.erb deleted file mode 100644 index b9773037389..00000000000 --- a/app/views/admin/inventory_units/adjust.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -<%= error_messages_for(:level) %> - -<% form_tag do %> -

    - -

    Inventory Adjustment

    - - - - - - - - - - - - - - - - - - - - -
    SKUProductOptionsCurrentAdjustment
    <%= @variant.sku %><%= @variant.product.name %><%= variant_options @variant %><%= on_hand(@variant) %> - <%= text_field :level, :adjustment, :class => "quantity" %> -
    - <%= submit_tag 'Update' %> or <%= link_to 'Cancel', :controller => 'overview', :action => :index %> -<% end %> - \ No newline at end of file diff --git a/app/views/admin/option_types/_form.rhtml b/app/views/admin/option_types/_form.rhtml deleted file mode 100644 index cd4a71a69c0..00000000000 --- a/app/views/admin/option_types/_form.rhtml +++ /dev/null @@ -1,16 +0,0 @@ - -

    - <%=error_message_on :option_type, :name%> - Name: <%= text_field 'option_type', 'name' -%> -

    -

    - Presentation: <%= text_field 'option_type', 'presentation' -%> -

    - -
    -
    - <%= render :partial => 'option_values', :locals => {:option_type => @option_type} -%> -
    -
    - - \ No newline at end of file diff --git a/app/views/admin/option_types/_new_option_value.rhtml b/app/views/admin/option_types/_new_option_value.rhtml deleted file mode 100644 index 548ce8e0712..00000000000 --- a/app/views/admin/option_types/_new_option_value.rhtml +++ /dev/null @@ -1,13 +0,0 @@ -

    New Option Value

    -<% fields_for :option_value, @option_value do |ov| %> - - - - - - - - - -
    Name<%= ov.text_field :name %>
    Display<%= ov.text_field :presentation %>
    -<% end %> diff --git a/app/views/admin/option_types/_option_values.rhtml b/app/views/admin/option_types/_option_values.rhtml deleted file mode 100644 index 8907abbf0d5..00000000000 --- a/app/views/admin/option_types/_option_values.rhtml +++ /dev/null @@ -1,43 +0,0 @@ -

    Option Values

    - - - - - - - - - - <% @option_type.option_values.each do |ov| %> - - - - - - <% end %> - <% if @option_type.option_values.empty? %> - - - - <% end %> - -
    NameDisplayAction
    <%=ov.name%><%=ov.presentation%> - <%= link_to_remote "Delete", - :url => {:action => 'delete_option_value', :id=> @option_type, :option_value_id => ov}, - :before => "Element.show('ov_busy_indicator')", - :complete => "Element.hide('ov_busy_indicator')", - :update => 'option-value-listing' %> -
    None Available.
    -<% unless @option_type.new_record? %> - - <%= link_to_remote "New Option Value", - :url => {:action => 'new_option_value', :id=> @option_type}, - :before => "Element.hide('new-ov-link');Element.show('ov_busy_indicator')", - :complete => "Element.hide('ov_busy_indicator');Element.hide('new-ov-link');", - :update => 'new-option-value' %> - - <%= image_tag "spinner.gif", :plugin=>"spree", :style => "display:none", :id => 'ov_busy_indicator' %> -<% end %> -
    -
    -
    \ No newline at end of file diff --git a/app/views/admin/option_types/edit.rhtml b/app/views/admin/option_types/edit.rhtml deleted file mode 100644 index ae389996bfe..00000000000 --- a/app/views/admin/option_types/edit.rhtml +++ /dev/null @@ -1,8 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -

    Editing Option Types

    - -<% form_for(:option_type) do |f| %> - <%= render :partial => 'form' %> - <%= submit_tag 'Update' %> or <%= link_to 'Cancel', :action => :index %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/option_types/index.rhtml b/app/views/admin/option_types/index.rhtml deleted file mode 100644 index a3fa14361a6..00000000000 --- a/app/views/admin/option_types/index.rhtml +++ /dev/null @@ -1,23 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -

    Listing Option Types

    - - - - -<% @option_types.each do |ot| %> - - - - -<% end %> -
    Name -
    <%= ot.name -%> - <%= link_to "Edit", :action => 'edit', :id => ot -%> - <%= link_to "Delete", - {:action => 'destroy', :id => ot}, - {:confirm => "Are you sure you want to delete this option type?"} -%> -
    - -

    -<%= link_to "Add Option Type", :action => 'new' -%> \ No newline at end of file diff --git a/app/views/admin/option_types/new.rhtml b/app/views/admin/option_types/new.rhtml deleted file mode 100644 index c416129ff2b..00000000000 --- a/app/views/admin/option_types/new.rhtml +++ /dev/null @@ -1,8 +0,0 @@ -<%= render :partial => 'admin/products/menu' %> - -

    New Option Type

    -
    -<% form_tag( {:action => 'new' }) do %> - <%= render :partial => 'form' %> - <%= submit_tag "Create" %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/option_types/select.rhtml b/app/views/admin/option_types/select.rhtml deleted file mode 100644 index 110e3dfdf23..00000000000 --- a/app/views/admin/option_types/select.rhtml +++ /dev/null @@ -1,28 +0,0 @@ -

    Available Option Types

    - - - - - - - - - - <% @option_types.each do |ot| %> - - - - - - <% end %> - <% if @option_types.empty? %> - - - - <% end %> - -
    NameDisplay
    <%=ot.name%><%=ot.display%> - <%= link_to_remote "Select", - :url => {:controller => 'products', :action => 'add_option_type', :id=> @product, :option_type_id => ot}, - :update => 'option-type-listing' %> -
    None Available.
    diff --git a/app/views/admin/orders/_address.rhtml b/app/views/admin/orders/_address.rhtml deleted file mode 100644 index 924b6ac7820..00000000000 --- a/app/views/admin/orders/_address.rhtml +++ /dev/null @@ -1,37 +0,0 @@ -<% -# HACK - in_place_editor field works only with instance variables and not local variables -# See http://dev.rubyonrails.org/ticket/3094 for details - -@address = address -%> - - - - - - - - - - - - - - - - - - - - -
    - <%=in_place_editor_field :address, :firstname %> <%=in_place_editor_field :address, :lastname %> -
    - <%=in_place_editor_field :address, :address1 %> -
    - <%=in_place_editor_field :address, :address2 %> -
    - <%=in_place_editor_field :address, :city %>, <%= @address.state.abbr %> <%=in_place_editor_field :address, :zipcode %> -
    - <%=@address.country.name%> -
    <%=in_place_editor_field :address, :phone %>
    \ No newline at end of file diff --git a/app/views/admin/orders/index.rhtml b/app/views/admin/orders/index.rhtml deleted file mode 100644 index 0d8285d5a55..00000000000 --- a/app/views/admin/orders/index.rhtml +++ /dev/null @@ -1,99 +0,0 @@ -<%= calendar_date_select_includes %> -

    Listing orders

    - - - - - -
    -
    - - - - - - - - - - - - - <% for order in @orders%> - - - - - - - - - <% end %> - -
    Order DateNumberStatusCustomerTotalAction
    <%=order.created_at.to_date%><%= link_to order.number, {:action => 'show', :id => order} %><%=Order::Status.from_value order.status%><%=order.bill_address.full_name%><%=number_to_currency order.total%> - <% action_links(order).each do |a| %> - <%= link_to a.to_s.humanize.titleize, :action => a, :id => order -%> - <% end %> -
    -
    -
    - <% form_for :search do |f| %> -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <%=submit_tag "Search"%> -
    - <% end %> -
    -<%= render :partial => 'shared/paginate', :locals => {:collection => @orders, :options => search_options} unless @orders.empty? -%> - - diff --git a/app/views/admin/orders/show.rhtml b/app/views/admin/orders/show.rhtml deleted file mode 100644 index 32244eac5d9..00000000000 --- a/app/views/admin/orders/show.rhtml +++ /dev/null @@ -1,72 +0,0 @@ -<% content_for :action_nav do %> - -<% end %> - -<%= render :partial => 'shared/order_details', :locals => {:order => @order} -%> - - - - - - - - - - -
    Ship AddressBill Address
    - <%= render :partial => 'address', :locals => {:address => @order.ship_address} -%> - - <%= render :partial => 'address', :locals => {:address => @order.bill_address} -%> -
    - - - - - - - -
    Email
    <%=in_place_editor_field :user, :email %>
    - - - - - - - <% @order.order_operations.each do |o| %> - - - - - - <% end %> - <% if @order.order_operations.empty? %> - - - - <% end %> -
    OperationUserDate/Time
    <%=OrderOperation::OperationType.from_value o.operation_type%><%=o.user.login%><%=o.created_at.to_s(:db)%>
    None Available.
    - - - - - - - - - - <% if @order.credit_card %> - <% @order.credit_card.txns.each do |t| %> - - - - - - - - - <% end %> - <% end %> -
    TransactionAmountCard NumberTypeResponse CodeDate/Time
    <%=Txn::TxnType.from_value t.txn_type.to_i%><%=t.amount%><%=t.credit_card.display_number%><%=t.credit_card.cc_type%><%=t.response_code%><%=t.created_at.to_s(:db)%>
    -<%= link_to "Back", :controller => :orders %> \ No newline at end of file diff --git a/app/views/admin/overview/index.rhtml b/app/views/admin/overview/index.rhtml deleted file mode 100644 index c6a8845e18c..00000000000 --- a/app/views/admin/overview/index.rhtml +++ /dev/null @@ -1,3 +0,0 @@ -

    Overview

    - -Welcome to the Overview Page. This is a placeholder for some real content. Eventually we envision a bunch of "widgets" summarizing recent activity and other elements of interest. \ No newline at end of file diff --git a/app/views/admin/products/_form.rhtml b/app/views/admin/products/_form.rhtml deleted file mode 100644 index 8810a11e018..00000000000 --- a/app/views/admin/products/_form.rhtml +++ /dev/null @@ -1,78 +0,0 @@ - -

    -
    - <%=error_message_on :product, :name%> - <%= text_field :product, :name %> -

    -

    -
    - <%=error_message_on :product, :description%> - <%= text_area :product, :description, {:cols => 90, :rows => 10} %> -

    -

    -

    -
    - <%=error_message_on :product, :price%> - <%= text_field :product, :price %>

    -

    -<% if @product.new_record? %> -

    -
    - -

    -

    -
    - -

    -<% end %> -<% if @product.variant %> -

    -
    - -

    -

    - On Hand: <%= on_hand(@product.variant) %> <%= link_to "Inventory", :controller => 'inventory_units', :action => 'adjust', :id => @product.variant %> -

    -<% end %> -

    -
    - - <%= observe_field :category, - :update => 'treatmentWrapper', - :url => {:action => :tax_treatments}, - :with => "'id=#{@product.id}&category_id=' + encodeURIComponent(value)", - :on => 'changed' -%> -

    -
    -
    - <%= render :partial => 'variants', :locals => {:product => @product} -%> -
    -
    -
    - <%= render :partial => 'option_types', :locals => {:product => @product} -%> -
    -
    -
    - <%= render :partial => 'shared/images', :locals => {:viewable => @product} -%> -
    - -

    -
    - -

    -

    - Space delimited, no special characters. -

    -
    - -
    - <%= render :partial => 'shared/tax_treatments', :locals => {:tax_treatments => @all_tax_treatments, :selected_treatments => @product.tax_treatments} -%> -
    -
    - - - diff --git a/app/views/admin/products/_menu.rhtml b/app/views/admin/products/_menu.rhtml deleted file mode 100644 index 26ca942e25c..00000000000 --- a/app/views/admin/products/_menu.rhtml +++ /dev/null @@ -1,7 +0,0 @@ -<% content_for :action_nav do %> - -<% end %> \ No newline at end of file diff --git a/app/views/admin/products/_new_variant.rhtml b/app/views/admin/products/_new_variant.rhtml deleted file mode 100644 index 29fd29af548..00000000000 --- a/app/views/admin/products/_new_variant.rhtml +++ /dev/null @@ -1,11 +0,0 @@ -

    New Variant

    - - <% @product.selected_options.each do |so| %> - - - - - <% end %> -
    <%=so.option_type.presentation%> - <%= option_type_select so %> -
    diff --git a/app/views/admin/products/_option_types.rhtml b/app/views/admin/products/_option_types.rhtml deleted file mode 100644 index 1d14e8410a6..00000000000 --- a/app/views/admin/products/_option_types.rhtml +++ /dev/null @@ -1,42 +0,0 @@ -

    Selected Option Types

    - - - - - - - - - - <% @product.selected_options.each do |so| %> - - - - - - <% end %> - <% if @product.selected_options.empty? %> - - - - <% end %> - -
    NameDisplayAction
    <%=so.option_type.name%><%=so.option_type.presentation%> - <%= link_to_remote "Remove", - :url => {:action => 'remove_option_type', :id=> @product, :product_option_type_id => so}, - :before => "Element.hide('select-link');Element.show('busy_indicator')", - :complete => "Element.hide('busy_indicator')", - :update => 'option-type-listing' %> -
    None Selected.
    -<% unless @product.new_record? %> - - <%= link_to_remote "Select Option Type", - :url => {:controller => 'option_types', :action => 'select', :id=> @product}, - :before => "Element.hide('select-link');Element.show('busy_indicator')", - :complete => "Element.hide('busy_indicator');", - :update => 'new-option-type' %> - - <%= image_tag "spinner.gif", :plugin=>"spree", :style => "display:none", :id => 'busy_indicator' %> -<% end %> -
    -
    diff --git a/app/views/admin/products/_variants.rhtml b/app/views/admin/products/_variants.rhtml deleted file mode 100644 index b08e3591f83..00000000000 --- a/app/views/admin/products/_variants.rhtml +++ /dev/null @@ -1,50 +0,0 @@ -

    Variants

    -<%=error_message_on :product, :variants %> - - - - - - - - - - - <% if @product.variants? %> - <% @product.variants.each do |@variant| %> - <% next if @variant.option_values.empty? %> - - - - - - - <% end %> - <% else %> - - - - <% end %> - -
    OptionsSKUOn HandAction
    <%= variant_options @variant %> - <%= text_field :variant, :sku, :index => @variant.id, :class => :sku %> - <%= on_hand(@variant) %> - <%= link_to "Inventory", :controller => 'inventory_units', :action => 'adjust', :id => @variant %> - <%= link_to_remote "Remove", - :url => {:action => 'delete_variant', :id=> @product, :variant_id => @variant}, - :before => "Element.show('busy_indicator')", - :complete => "Element.hide('busy_indicator')", - :update => 'variant-listing' %> -
    None Available.
    -<% unless @product.new_record? or @product.selected_options.empty? %> - - <%= link_to_remote "New Product Variant", - :url => {:controller => 'products', :action => 'new_variant', :id=> @product}, - :before => "Element.hide('new-var-link');Element.show('var_busy_indicator')", - :complete => "Element.hide('var_busy_indicator')", - :update => 'new-variant' %> - - <%= image_tag "spinner.gif", :plugin=>"spree", :style => "display:none", :id => 'var_busy_indicator' %> -<% end %> - -
    \ No newline at end of file diff --git a/app/views/admin/products/edit.rhtml b/app/views/admin/products/edit.rhtml deleted file mode 100644 index f2f5287dffc..00000000000 --- a/app/views/admin/products/edit.rhtml +++ /dev/null @@ -1,8 +0,0 @@ -<%= render :partial => 'menu' -%> - -

    Editing Product

    - -<% form_for(:product, :html => { :multipart => true}) do |f| %> - <%= render :partial => 'form', :locals => {:f => f} %> - <%= submit_tag 'Update' %> or <%= link_to 'Cancel', :action => :index %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/products/index.rhtml b/app/views/admin/products/index.rhtml deleted file mode 100644 index b411e1d18fc..00000000000 --- a/app/views/admin/products/index.rhtml +++ /dev/null @@ -1,64 +0,0 @@ -<%= render :partial => 'menu' -%> - -<%= link_to "Add Product", :action => 'new' -%> - -

    - -

    Listing products

    - - - - - -
    - - - - - - - - - <% for product in @products%> - - - - - - - - <% end %> -
    ImageTitlePriceTags -
    <%= mini_image(product) %><%=product.name%><%=product.price%><%=product.tag_list%> - <%= link_to "Edit", :action => 'edit', :id => product -%> - <%= link_to "Delete", {:action => 'destroy', :id => product}, :confirm => "Are you sure you want to delete this product?" -%> -
    - <%= render :partial => 'shared/paginate', :locals => {:collection => @products, :options => search_options} -%> -
    - <% form_for :search do |f| %> -
    - - - - - - - - - - - - - - - - - - <%=submit_tag "Search"%> -
    - <% end %> -
    diff --git a/app/views/admin/products/new.rhtml b/app/views/admin/products/new.rhtml deleted file mode 100644 index cfc479c13f1..00000000000 --- a/app/views/admin/products/new.rhtml +++ /dev/null @@ -1,9 +0,0 @@ -<%= render :partial => 'menu' -%> - -<% form_for(:product, :html => { :multipart => true }) do |f| %> - <%= render :partial => 'form', :locals => {:f => f} %> -
    - <%= submit_tag "Create" %> -<% end %> - -<%= link_to 'Back', :action => :index %> \ No newline at end of file diff --git a/app/views/admin/reports/index.rhtml b/app/views/admin/reports/index.rhtml deleted file mode 100644 index 5b2397ec916..00000000000 --- a/app/views/admin/reports/index.rhtml +++ /dev/null @@ -1,29 +0,0 @@ -<%= calendar_date_select_includes %> -

    Listing reports

    - - - - -
    -
    - - - - - - - - - <% @reports.each do |key, value| %> - - - - - <% end %> - -
    NameDescription
    <%=link_to value[:name], :action => key%><%=value[:description]%>
    -
    -
    - - - diff --git a/app/views/admin/reports/sales_total.rhtml b/app/views/admin/reports/sales_total.rhtml deleted file mode 100644 index d501730a070..00000000000 --- a/app/views/admin/reports/sales_total.rhtml +++ /dev/null @@ -1,28 +0,0 @@ -

    Sales Totals

    - - - - <%= render :partial => 'shared/report_criteria', :locals => {} %> - -
    - - - - - - - - - - - - - - - - - - - -
    Item Total: <%=number_to_currency @item_total%>
    Shipping Total: <%=number_to_currency @ship_total%>
    Tax Total: <%=number_to_currency @tax_total%>
    Sales Total: <%=number_to_currency @sales_total%>
    -
    \ No newline at end of file diff --git a/app/views/admin/users/_form.rhtml b/app/views/admin/users/_form.rhtml deleted file mode 100644 index 2766a3cdf88..00000000000 --- a/app/views/admin/users/_form.rhtml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - -
    Login: - <%= error_message_on :user, :login%> - <%= f.text_field :login %> -
    Email: - <%= error_message_on :user, :email%> - <%= f.text_field :email %> -
    Password: - <%= error_message_on :user, :password%> - <%= f.password_field :password %> -
    Confirm: - <%= f.password_field :password_confirmation %> -
    diff --git a/app/views/admin/users/_menu.rhtml b/app/views/admin/users/_menu.rhtml deleted file mode 100644 index 43fba9ebb2d..00000000000 --- a/app/views/admin/users/_menu.rhtml +++ /dev/null @@ -1,5 +0,0 @@ -<% content_for :action_nav do %> - -<% end %> \ No newline at end of file diff --git a/app/views/admin/users/edit.rhtml b/app/views/admin/users/edit.rhtml deleted file mode 100644 index b67ca4f1a59..00000000000 --- a/app/views/admin/users/edit.rhtml +++ /dev/null @@ -1,8 +0,0 @@ -<%= render :partial => 'menu' -%> - -

    Editing User

    - -<% form_for :user, :action => :update do |f| %> - <%= render :partial => 'form', :locals => {:f => f} %> - <%= submit_tag 'Update' %> or <%= link_to 'Cancel', :action => :index %> -<% end %> \ No newline at end of file diff --git a/app/views/admin/users/index.rhtml b/app/views/admin/users/index.rhtml deleted file mode 100644 index a626b267b08..00000000000 --- a/app/views/admin/users/index.rhtml +++ /dev/null @@ -1,26 +0,0 @@ -<%= render :partial => 'menu' -%> - -<%= link_to "Add User", :action => 'new' -%> - -

    - -

    Listing users

    - - - - - -<% for user in @users%> - - - - - -<% end %> -
    LoginEmail -
    <%=user.login%><%=user.email%> - <%= link_to "Show", :action => 'show', :id => user -%> - <%= link_to "Edit", :action => 'edit', :id => user -%> - <%= link_to "Delete", {:action => 'destroy', :id => user}, :confirm => "Are you sure you want to delete this user?" -%> -
    -<%= render :partial => 'shared/paginate', :locals => {:collection => @users, :options => {}} -%> diff --git a/app/views/admin/users/new.rhtml b/app/views/admin/users/new.rhtml deleted file mode 100644 index eacbe3fd120..00000000000 --- a/app/views/admin/users/new.rhtml +++ /dev/null @@ -1,6 +0,0 @@ -<%= render :partial => 'menu' -%> -

    Editing User

    -<% form_for :user, :url => { :action => 'new' } do |f| -%> - <%= render :partial => 'form', :locals => { :f => f, :create => true } %> - <%= submit_tag "Create" %> -<% end -%> diff --git a/app/views/admin/users/show.rhtml b/app/views/admin/users/show.rhtml deleted file mode 100644 index d5c7d5eea5c..00000000000 --- a/app/views/admin/users/show.rhtml +++ /dev/null @@ -1,9 +0,0 @@ -<%= render :partial => 'menu' -%> - -
    - <% for column in User.content_columns %> -

    - <%= column.human_name %>: <%=h @user.send(column.name) %> -

    - <% end %> -
    \ No newline at end of file diff --git a/app/views/cart/index.rhtml b/app/views/cart/index.rhtml deleted file mode 100644 index bcc6a27e475..00000000000 --- a/app/views/cart/index.rhtml +++ /dev/null @@ -1,49 +0,0 @@ -
    -

    Shopping Cart

    - - <%=error_messages_for :cart_item%> - - <% form_tag do-%> - - - - - - - - - <% for @item in @cart_items %> - - - - - - - - <% end %> -
    ItemPriceQtyTotal
    - <%= small_image(@item.variant.product) %> - - <%=link_to @item.variant.product.name, :controller => 'store', :action => 'show', :id => @item.variant.product %> - <%= variant_options @item.variant %>
    - <%=truncate(@item.variant.product.description, length = 100, truncate_string = "...")-%> -
    $ <%= sprintf("%0.2f", @item.price) %><%= text_field "item[]", :quantity, :size => 3 -%>$ <%= sprintf("%0.2f", @item.price * @item.quantity) unless @item.quantity.nil? %>
    -
    -

    Subtotal $ <%= sprintf("%0.2f", @cart.total) %>

    - <%= submit_tag 'Update' %> - <%= link_to "Checkout", :controller => 'checkout' %> -
    - <% end %> - <%if previous_location %> -

    <%=link_to "Continue Shopping", :controller => 'store', :action => 'index'%>

    - <%end%> - <% unless @cart.cart_items.empty? %> - - <% end %> -
    \ No newline at end of file diff --git a/app/views/checkout/_address.rhtml b/app/views/checkout/_address.rhtml deleted file mode 100644 index 294d980ce98..00000000000 --- a/app/views/checkout/_address.rhtml +++ /dev/null @@ -1,51 +0,0 @@ -<% fields_for "#{address_type}_address", @address do |a| %> - - First Name - <%= a.text_field :firstname %> - <%= error_message_on "#{address_type}_address", :firstname -%> - - - Last Name - <%= a.text_field :lastname %> - <%= error_message_on "#{address_type}_address", :lastname -%> - - - Street Address - <%= a.text_field :address1 %> - <%= error_message_on "#{address_type}_address", :address1 -%> - - - Street Address (cont'd) - <%= a.text_field :address2 %> - - - - City - <%= a.text_field :city %> - <%= error_message_on "#{address_type}_address", :city -%> - - - State - - <%= a.collection_select :state_id, @states, :id, :name, {}, {:style => "width:100%"}%> - - - - - Zip - <%= a.text_field :zipcode %> - <%= error_message_on "#{address_type}_address", :zipcode -%> - - <% if (@countries.size > 1) %> - - Country - <%= a.collection_select :country_id, @countries, :id, :name, {}, {:style => "width:100%"}%> - - - <% end %> - - Phone - <%= a.text_field :phone %> - <%= error_message_on "#{address_type}_address", :phone -%> - -<% end %> \ No newline at end of file diff --git a/app/views/checkout/_address_ro.rhtml b/app/views/checkout/_address_ro.rhtml deleted file mode 100644 index a0a9a8e4e93..00000000000 --- a/app/views/checkout/_address_ro.rhtml +++ /dev/null @@ -1,18 +0,0 @@ - - <%= address.firstname %> <%= address.lastname %> - - - <%= address.address1 %> - - - <%= address.address2 %> - - - <%= address.city %>, <%= address.state.name %> <%= address.zipcode %> - - - <%= address.country.name %> - - - <%= address.phone %> - \ No newline at end of file diff --git a/app/views/checkout/_cart_item.rhtml b/app/views/checkout/_cart_item.rhtml deleted file mode 100644 index d11bd575fd0..00000000000 --- a/app/views/checkout/_cart_item.rhtml +++ /dev/null @@ -1,5 +0,0 @@ -<% @cart_item = cart_item %> - - <%= @cart_item.product.name -%> - <%= @cart_item.quantity -%> - diff --git a/app/views/checkout/_extra.rhtml b/app/views/checkout/_extra.rhtml deleted file mode 100644 index fbad3635929..00000000000 --- a/app/views/checkout/_extra.rhtml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/views/checkout/addresses.rhtml b/app/views/checkout/addresses.rhtml deleted file mode 100644 index 52a2f82fe3a..00000000000 --- a/app/views/checkout/addresses.rhtml +++ /dev/null @@ -1,51 +0,0 @@ -<%=error_messages_for :order%> - -

    Address Information

    -
    -<% form_for :order, @order, :url => { :action => :addresses } do |f| %> - - - - - <% fields_for :user, @user do |u| %> - - - - - - <% end %> -
    Email
    Email Address<%= u.text_field :email -%><%= error_message_on :user, :email -%>
    - - - - - <%= render :partial => 'address', :locals => {:address_type => 'bill', :address => @bill_address} -%> - - - -
    Billing Address
    -
    -
    - > - - - - <%= render :partial => 'address', :locals => {:address_type => 'ship', :address => @ship_address} -%> -
    Shipping Address
    - <% if (Order::ShipMethod.constants.size > 1) %> - - - - - - - - -
    Shipping
    - Shipping Method - - <%= f.collection_select :ship_method, Order::ShipMethod.constants, :id, :title, {}, {:style => "width:200px"}%> -
    - <% end %> - <%= submit_tag "Next" %> -<% end %> diff --git a/app/views/checkout/cvv.rhtml b/app/views/checkout/cvv.rhtml deleted file mode 100644 index d919bfe423f..00000000000 --- a/app/views/checkout/cvv.rhtml +++ /dev/null @@ -1,15 +0,0 @@ -<%=stylesheets%> - -
    -

    What is a (CVV) Credit Card Code?

    -

    For Visa, MasterCard, and Discover cards, the card code is the last 3 digit number located on the back of your card on or above your signature line. For an American Express card, it is the 4 digits on the FRONT above the end of your card number.

    -

    To help reduce fraud in the card-not-present environment, credit card companies have introduced a card code program. Visa calls this code Card Verification Value (CVV); MasterCard calls it Card Validation Code (CVC); Discover calls it Card ID (CID). The card code is a three- or four- digit security code that is printed on the back of cards. The number typically appears at the end of the signature panel.

    -
    Visa
    - <%= image_tag "visa_cid.gif", :plugin=>"spree" %> -
    Master Card
    - <%= image_tag "master_cid.jpg", :plugin=>"spree" %> -
    American Express
    - <%= image_tag "amex_cid.gif", :plugin=>"spree" %> -
    Discover
    - <%= image_tag "discover_cid.gif", :plugin=>"spree" %> -
    \ No newline at end of file diff --git a/app/views/checkout/empty_cart.rhtml b/app/views/checkout/empty_cart.rhtml deleted file mode 100644 index 290912db10d..00000000000 --- a/app/views/checkout/empty_cart.rhtml +++ /dev/null @@ -1,4 +0,0 @@ -

    There are no items in your cart.

    -
    - -<%= link_to "Continue shopping", :controller => 'store', :action => 'index' -%> diff --git a/app/views/checkout/final_confirmation.rhtml b/app/views/checkout/final_confirmation.rhtml deleted file mode 100644 index f706609f75c..00000000000 --- a/app/views/checkout/final_confirmation.rhtml +++ /dev/null @@ -1,92 +0,0 @@ -

    Payment

    -
    - - - <%= error_messages_for :cc %> - -<% form_for :creditcard, @cc, :url => { :action => :final_confirmation } do |card| %> - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - <%= image_tag "creditcard.gif", :plugin=>"spree", :id => 'creditcard-image' %> -
    Card Number<%= card.text_field :number, :maxlength => 17 -%>
    Expiration Month<%= card.text_field :month -%>
    Expiration Year<%= card.text_field :year -%>
    Type <%= error_message_on :cc, :type -%><%= card.select :type, credit_card_types -%>
    Card Code - <%= card.text_field :verification_value, {:style => "width:40px"} -%> - (What's This?) -
    - - <%= render :partial => 'extra', :locals => {:order=> @order} -%> -
    -

    Final Confirmation

    -
    - - - - - - - - - - - - - - - - - - - - -
    Order
    Item Total: <%= number_to_currency @order.item_total -%>
    Tax: <%= number_to_currency @order.tax_amount -%>
    Shipping: <%= number_to_currency @order.ship_amount -%>
    Order Total: <%= number_to_currency @order.total -%>
    -
    - - - - - -
    - - - - - <%= render :partial => 'address_ro', :locals => {:address_type => 'ship', :address => @order.bill_address} -%> -
    Billing Address
    -
    - - - - - <%= render :partial => 'address_ro', :locals => {:address_type => 'ship', :address => @order.ship_address} -%> -
    Shipping Address
    -
    -
    - <%= submit_tag "Process" %> - <% if logged_in? and current_user.has_role?("admin") %> - <%= link_to "Comp Order", {:action => "comp"}, {:confirm => "Customer will not be charged. Are you sure you want to comp this order?"} %> - <% end %> -<% end %> diff --git a/app/views/checkout/incomplete.rhtml b/app/views/checkout/incomplete.rhtml deleted file mode 100644 index f0f413a8631..00000000000 --- a/app/views/checkout/incomplete.rhtml +++ /dev/null @@ -1,7 +0,0 @@ -

    We had problems processing your order

    -
    -<%= error_messages_for 'order' %> -<%= error_messages_for 'txn' %> -
    - -<%= link_to "Try Again", :action => :final_confirmation %> diff --git a/app/views/checkout/thank_you.rhtml b/app/views/checkout/thank_you.rhtml deleted file mode 100644 index bfed4280846..00000000000 --- a/app/views/checkout/thank_you.rhtml +++ /dev/null @@ -1,11 +0,0 @@ -<%= link_to "Go Back To Store", :controller => :store %> -

    -

    Your order has been processed successfully

    -
    -Thank you for your business. Please print out a copy of this confirmation page for your records. -

    - -<%= render :partial => 'shared/order_details', :locals => {:order => @order} -%> - -
    -<%= link_to "Go Back To Store", :controller => :store %> diff --git a/app/views/layouts/admin.rhtml b/app/views/layouts/admin.rhtml deleted file mode 100644 index 06878990ed9..00000000000 --- a/app/views/layouts/admin.rhtml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - TODO: dynamic site title: Admin <%= controller.controller_name %> - <%= stylesheets %> - <%= stylesheet_link_tag "spree-admin.css", :plugin => "spree" %> - <%= javascript_include_tag :defaults %> - <%= yield :head %> - - -
    - <% if logged_in? -%> -
      -
    • Logged in as: <%= current_user.login -%>
    • -
    • <%= link_to 'Account', :controller => 'users', :action => 'show', :id => current_user %>
    • -
    • <%= link_to 'Logout', :controller => '/account', :action => 'logout' %>
    • -
    • <%= link_to 'Store', :controller => '/store' %>
    • -
    - <% end -%> - - - - - - <%= yield :form if @content_for_form %> - - - - - - - - - - - <%= '' if @content_for_form %> -
    <%= image_tag "spree/spree.jpg", :plugin=>"spree" %> - - <% if logged_in? -%> -
    -
      -
    • <%= link_to 'Overview', :controller => '/admin/overview' %>
    • -
    • <%= link_to 'Orders', :controller => '/admin/orders' %>
    • -
    • <%= link_to 'Products', :controller => '/admin/products' %>
    • -
    • <%= link_to 'Reports', :controller => '/admin/reports' %>
    • -
    • <%= link_to 'Users', :controller => '/admin/users' %>
    • -
    -
    - <% end -%> -
    -
    - <%= yield :action_nav %> -
    -
     
    -
    - <% if flash[:error] %> -
    <%= flash[:error] %>
    - <% end %> - <% if flash[:notice] %> -
    <%= flash[:notice] %>
    - <% end %> -
    -
    - <%= yield %> -
    -
    - - \ No newline at end of file diff --git a/app/views/layouts/application.rhtml b/app/views/layouts/application.rhtml deleted file mode 100644 index ffd70bcd600..00000000000 --- a/app/views/layouts/application.rhtml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - Spree - <%= stylesheets %> - <%= javascript_include_tag :defaults %> - - -
    - - - - - <% if flash[:notice] %> - - <% end %> - <% if flash[:error] %> - - <% end %> - - - - -
    - - - - - - -
    - <%= image_tag 'spree.jpg' %> - -

    Welcome To The Sample Store

    -
    - <% if store_menu? %> - <%= render :partial => 'shared/store_menu' %> - <% end %> -
    -
    <%= flash[:notice] %>
    <%= flash[:error] %>
    - - -
    - <%=@content_for_layout %> -
    -
    -
    - - \ No newline at end of file diff --git a/app/views/layouts/products.rhtml b/app/views/layouts/products.rhtml deleted file mode 100644 index 8d12c60f25a..00000000000 --- a/app/views/layouts/products.rhtml +++ /dev/null @@ -1,19 +0,0 @@ - - - - Spree - - -
    -
    - <% if flash[:notice] %> -
    <%= flash[:notice] %>
    - <% end %> - <% if flash[:error] %> -
    ERROR - <%= flash[:error] %>
    - <% end %> - <%=@content_for_layout %> -
    -
    - - diff --git a/app/views/layouts/simple.rhtml b/app/views/layouts/simple.rhtml deleted file mode 100644 index a09014bb464..00000000000 --- a/app/views/layouts/simple.rhtml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Spree: <%= controller.controller_name %> Admin - <%= stylesheet_link_tag 'fullscreen', :plugin=>"spree" %> - <%= javascript_include_tag 'prototype', 'effects', 'lowpro', 'application' %> - - - - - - - - -
    - - -
    - - - - -
    - <%= yield %> -
    - - -
    - - -
    - - \ No newline at end of file diff --git a/app/views/order_mailer/cancel.rhtml b/app/views/order_mailer/cancel.rhtml deleted file mode 100644 index a24cf76f2c4..00000000000 --- a/app/views/order_mailer/cancel.rhtml +++ /dev/null @@ -1,15 +0,0 @@ -Dear <%= @order.bill_address.full_name%>, - -Your order has been CANCELED. Please retain this cancellation information for your records. - -============================================================ -Order Summary [CANCELED] -============================================================ -<% for item in @order.line_items -%> -<%=item.variant.sku %> <%=item.variant.product.name-%> <%= variant_options(item.variant) %> (<%=item.quantity-%>) @ $ <%= sprintf("%0.2f", item.price) %> = $ <%= sprintf("%0.2f", item.price * item.quantity) %> -<% end -%> -============================================================ -Subtotal: <%= number_to_currency @order.item_total %> -Tax: <%= number_to_currency @order.tax_amount %> -Shipping: <%= number_to_currency @order.ship_amount %> -Order Total: <%= number_to_currency @order.total %> diff --git a/app/views/order_mailer/confirm.rhtml b/app/views/order_mailer/confirm.rhtml deleted file mode 100644 index 7be7bdcecbf..00000000000 --- a/app/views/order_mailer/confirm.rhtml +++ /dev/null @@ -1,18 +0,0 @@ -Dear <%= @order.bill_address.full_name%>, - -Please review and retain the following order information for your records. - -============================================================ -Order Summary -============================================================ -<% for item in @order.line_items -%> -<%=item.variant.sku %> <%=item.variant.product.name-%> <%= variant_options(item.variant) %> (<%=item.quantity-%>) @ $ <%= sprintf("%0.2f", item.price) %> = $ <%= sprintf("%0.2f", item.price * item.quantity) %> -<% end -%> -============================================================ -Subtotal: <%= number_to_currency @order.item_total %> -Tax: <%= number_to_currency @order.tax_amount %> -Shipping: <%= number_to_currency @order.ship_amount %> -Order Total: <%= number_to_currency @order.total %> - - -Thank you for your business. \ No newline at end of file diff --git a/app/views/shared/_images.html.erb b/app/views/shared/_images.html.erb deleted file mode 100644 index 9a07c5a9716..00000000000 --- a/app/views/shared/_images.html.erb +++ /dev/null @@ -1,43 +0,0 @@ -

    Images

    -<%=error_message_on :viewable, :images %> - - - - - - - - - <% viewable.images.each do |i| %> - - - - - <% end %> - <% if viewable.images.empty? %> - - - - <% end %> - -
    ImageAction
    <%= image_tag i.public_filename(:small) %> - <%= link_to_remote "Delete", - :url => {:controller => 'images', :action => 'delete', :id=> i}, - :before => "Element.show('img_busy_indicator')", - :complete => "Element.hide('img_busy_indicator')", - :confirm => "Are you sure you want to delete this image?", - :update => 'image-listing' %> -
    None Available.
    -<% unless viewable.new_record? %> - - <%= link_to_remote "New Image", - :url => {:controller => 'images', :action => 'new'}, - :before => "Element.hide('new-img-link');Element.show('img_busy_indicator')", - :complete => "Element.hide('img_busy_indicator')", - :update => 'new-image' %> - - <%= image_tag "spinner.gif", :plugin=>"spree", :style => "display:none", :id => 'img_busy_indicator' %> -<% end %> -
    -
    -
    \ No newline at end of file diff --git a/app/views/shared/_order_details.rhtml b/app/views/shared/_order_details.rhtml deleted file mode 100644 index 8d92021399e..00000000000 --- a/app/views/shared/_order_details.rhtml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - <% for item in @order.line_items %> - - - - - - - <% end %> - - - - - - - - - - - - - - - - -
    Order # <%=@order.number%>
    Item DescriptionPriceQtyTotal
    <%=item.variant.product.name-%> <%= "(" + variant_options(item.variant) + ")" unless item.variant .option_values.empty? %><%= number_to_currency item.price -%><%=item.quantity-%><%= number_to_currency (item.price * item.quantity)-%>
    Subtotal: <%= number_to_currency @order.item_total -%>
    Tax: <%= number_to_currency @order.tax_amount -%>
    Shipping: <%= number_to_currency @order.ship_amount -%>
    Order Total: <%= number_to_currency @order.total -%>
    \ No newline at end of file diff --git a/app/views/shared/_paginate.rhtml b/app/views/shared/_paginate.rhtml deleted file mode 100644 index 09b8bbacc8f..00000000000 --- a/app/views/shared/_paginate.rhtml +++ /dev/null @@ -1,34 +0,0 @@ - -<% if collection.page_count != collection.first_page -%> - -<% end -%> \ No newline at end of file diff --git a/app/views/shared/_report_criteria.rhtml b/app/views/shared/_report_criteria.rhtml deleted file mode 100644 index 5c58a296ac5..00000000000 --- a/app/views/shared/_report_criteria.rhtml +++ /dev/null @@ -1,32 +0,0 @@ -<%= calendar_date_select_includes %> - - <% form_for :search do |f| %> - - <% end %> - diff --git a/app/views/shared/_store_menu.rhtml b/app/views/shared/_store_menu.rhtml deleted file mode 100644 index 146b64181fe..00000000000 --- a/app/views/shared/_store_menu.rhtml +++ /dev/null @@ -1,6 +0,0 @@ -
    -
      -
    • <%= link_to "Cart", :controller => :cart, :action => :index %>
    • -
    • <%= link_to "Checkout", :controller => :checkout, :action => :index %>
    • -
    -
    \ No newline at end of file diff --git a/app/views/shared/_tax_treatments.rhtml b/app/views/shared/_tax_treatments.rhtml deleted file mode 100644 index edaf3bd6f70..00000000000 --- a/app/views/shared/_tax_treatments.rhtml +++ /dev/null @@ -1,12 +0,0 @@ -
    -

    Tax Treatments:

    - <% if selected_treatments.frozen? %> - <%= selected_treatments.collect { |t| t.name }.to_sentence -%> - <% else %> - <% selected_treatments = selected_treatments.collect { |t| t.id.to_i } %> - -
    - <% end%> -
    \ No newline at end of file diff --git a/app/views/store/_image.html.erb b/app/views/store/_image.html.erb deleted file mode 100644 index 90438040bd0..00000000000 --- a/app/views/store/_image.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<% if image %> - <%= image_tag image.public_filename(:product) %> -<% else %> - <%= product_image(@product) %> -<% end %> \ No newline at end of file diff --git a/app/views/store/_products.rhtml b/app/views/store/_products.rhtml deleted file mode 100644 index 9b166bbb6d7..00000000000 --- a/app/views/store/_products.rhtml +++ /dev/null @@ -1,10 +0,0 @@ -

    Products

    - \ No newline at end of file diff --git a/app/views/store/_thumbnails.html.erb b/app/views/store/_thumbnails.html.erb deleted file mode 100644 index 38a7fedaa92..00000000000 --- a/app/views/store/_thumbnails.html.erb +++ /dev/null @@ -1,9 +0,0 @@ - -<% if product.images.size > 1 %> - <% product.images.each do |i| %> - <%= link_to_remote image_tag(i.public_filename(:mini)), - :url => {:controller => 'store', :action => 'change_image', :id => product, :image_id => i}, - :update => 'product-image' - %> - <% end %> -<% end %> \ No newline at end of file diff --git a/app/views/store/list.rhtml b/app/views/store/list.rhtml deleted file mode 100644 index f4195482f3f..00000000000 --- a/app/views/store/list.rhtml +++ /dev/null @@ -1,14 +0,0 @@ -
    -

    Products


    - <% for product in @products %> -
  • - - <%= small_image(product) %> - - <%= link_to product.name, :controller => :store, :action => 'show', :id => product %> - $<%=product.price%> -
  • - <%= draggable_element("product_#{product.id}", :revert => true) %> - - <% end %> -
    diff --git a/app/views/store/show.rhtml b/app/views/store/show.rhtml deleted file mode 100644 index eb1e61fb413..00000000000 --- a/app/views/store/show.rhtml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - -
    -
    - <%= render :partial => 'image' -%> -
    -
    - <%= render :partial => 'thumbnails', :locals => {:product => @product} -%> -
    -
    - - - - - - - - - - - <% form_for :product, :url => {:controller => :cart, :action => :add} do |f| %> - <% if @product.variants? %> - - - - - - - <% else %> - /> - <% end%> - <% if @product.tags.size > 0 %> - - - - <% end %> - - - - <% end %> -
    <%= @product.name %>
    <%= @product.description %>
    - Price: - <%= @product.price %> -
    Variants:
    -
    -
      - <% @product.variants.each do |v| %> - <% next if v.option_values.empty? %> -
    • - /> - <%= variant_options v%> -
    • - <% end%> -
    -
    -
    - -
    - <%= submit_tag 'Add To Cart' %> -
    -
    - - - diff --git a/backend/Gemfile b/backend/Gemfile new file mode 100644 index 00000000000..13f166ada73 --- /dev/null +++ b/backend/Gemfile @@ -0,0 +1,6 @@ +eval(File.read(File.dirname(__FILE__) + '/../common_spree_dependencies.rb')) + +gem 'spree_core', path: '../core' +gem 'spree_api', path: '../api' + +gemspec diff --git a/backend/Rakefile b/backend/Rakefile new file mode 100644 index 00000000000..157d91b8ae0 --- /dev/null +++ b/backend/Rakefile @@ -0,0 +1,15 @@ +require 'rubygems' +require 'rake' +require 'rake/testtask' +require 'rspec/core/rake_task' +require 'spree/testing_support/common_rake' + +RSpec::Core::RakeTask.new + +task default: :spec + +desc "Generates a dummy app for testing" +task :test_app do + ENV['LIB_NAME'] = 'spree/backend' + Rake::Task['common:test_app'].invoke +end diff --git a/backend/app/assets/images/admin/logo.png b/backend/app/assets/images/admin/logo.png new file mode 100644 index 00000000000..65d37c85a26 Binary files /dev/null and b/backend/app/assets/images/admin/logo.png differ diff --git a/backend/app/assets/images/credit_cards/credit_card.gif b/backend/app/assets/images/credit_cards/credit_card.gif new file mode 100644 index 00000000000..2e61a23c310 Binary files /dev/null and b/backend/app/assets/images/credit_cards/credit_card.gif differ diff --git a/backend/app/assets/javascripts/spree/backend.js b/backend/app/assets/javascripts/spree/backend.js new file mode 100644 index 00000000000..cdb71704862 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend.js @@ -0,0 +1,80 @@ +//= require modernizr +//= require bootstrap-sprockets +//= require handlebars +//= require jquery +//= require js.cookie +//= require jquery.jstree/jquery.jstree +//= require jquery_ujs +//= require jquery-ui/widgets/datepicker +//= require jquery-ui/widgets/sortable +//= require jquery-ui/widgets/autocomplete +//= require select2 +//= require underscore-min.js + +//= require spree +//= require spree/backend/spree-select2 +//= require spree/backend/address_states +//= require spree/backend/adjustments +//= require spree/backend/admin +//= require spree/backend/calculator +//= require spree/backend/checkouts/edit +//= require spree/backend/gateway +//= require spree/backend/general_settings +//= require spree/backend/handlebar_extensions +//= require spree/backend/line_items +//= require spree/backend/line_items_on_order_edit +//= require spree/backend/option_type_autocomplete +//= require spree/backend/option_value_picker +//= require spree/backend/orders/edit +//= require spree/backend/payments/edit +//= require spree/backend/payments/new +//= require spree/backend/product_picker +//= require spree/backend/progress +//= require spree/backend/promotions +//= require spree/backend/returns/expedited_exchanges_warning +//= require spree/backend/returns/return_item_selection +//= require spree/backend/shipments +//= require spree/backend/states +//= require spree/backend/stock_location +//= require spree/backend/stock_management +//= require spree/backend/stock_movement +//= require spree/backend/stock_transfer +//= require spree/backend/taxon_autocomplete +//= require spree/backend/taxon_permalink_preview +//= require spree/backend/taxon_tree_menu +//= require spree/backend/taxonomy +//= require spree/backend/taxons +//= require spree/backend/users/edit +//= require spree/backend/user_picker +//= require spree/backend/tag_picker +//= require spree/backend/variant_autocomplete +//= require spree/backend/variant_management +//= require spree/backend/zone + +Spree.routes.clear_cache = Spree.adminPathFor('general_settings/clear_cache') +Spree.routes.checkouts_api = Spree.pathFor('api/v1/checkouts') +Spree.routes.classifications_api = Spree.pathFor('api/v1/classifications') +Spree.routes.option_types_api = Spree.pathFor('api/v1/option_types') +Spree.routes.option_values_api = Spree.pathFor('api/v1/option_values') +Spree.routes.orders_api = Spree.pathFor('api/v1/orders') +Spree.routes.products_api = Spree.pathFor('api/v1/products') +Spree.routes.shipments_api = Spree.pathFor('api/v1/shipments') +Spree.routes.checkouts_api = Spree.pathFor('api/v1/checkouts') +Spree.routes.stock_locations_api = Spree.pathFor('api/v1/stock_locations') +Spree.routes.taxon_products_api = Spree.pathFor('api/v1/taxons/products') +Spree.routes.taxons_api = Spree.pathFor('api/v1/taxons') +Spree.routes.users_api = Spree.pathFor('api/v1/users') +Spree.routes.tags_api = Spree.pathFor('api/v1/tags') +Spree.routes.variants_api = Spree.pathFor('api/v1/variants') + +Spree.routes.edit_product = function (productId) { + return Spree.adminPathFor('products/' + productId + '/edit') +} + +Spree.routes.payments_api = function (orderId) { + return Spree.pathFor('api/v1/orders/' + orderId + '/payments') +} + +Spree.routes.stock_items_api = function (stockLocationId) { + return Spree.pathFor('api/v1/stock_locations/' + stockLocationId + '/stock_items') +} diff --git a/backend/app/assets/javascripts/spree/backend/address_states.js b/backend/app/assets/javascripts/spree/backend/address_states.js new file mode 100644 index 00000000000..50035ee63f6 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/address_states.js @@ -0,0 +1,33 @@ +// eslint-disable-next-line camelcase, no-unused-vars +function update_state (region, done) { + 'use strict' + + var country = $('span#' + region + 'country .select2').select2('val') + var stateSelect = $('span#' + region + 'state select.select2') + var stateInput = $('span#' + region + 'state input.state_name') + + $.get(Spree.routes.states_search + '?country_id=' + country, function (data) { + var states = data.states + if (states.length > 0) { + stateSelect.html('') + var statesWithBlank = [{ + name: '', + id: '' + }].concat(states) + $.each(statesWithBlank, function (pos, state) { + var opt = $(document.createElement('option')) + .prop('value', state.id) + .html(state.name) + stateSelect.append(opt) + }) + stateSelect.prop('disabled', false).show() + stateSelect.select2() + stateInput.hide().prop('disabled', true) + } else { + stateInput.prop('disabled', false).show() + stateSelect.select2('destroy').hide() + } + + if (done) done() + }) +}; diff --git a/backend/app/assets/javascripts/spree/backend/adjustments.js b/backend/app/assets/javascripts/spree/backend/adjustments.js new file mode 100644 index 00000000000..f6f41d90df4 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/adjustments.js @@ -0,0 +1,25 @@ +/* global order_number, show_flash */ +$(function () { + $('[data-hook=adjustments_new_coupon_code] #add_coupon_code').click(function () { + var couponCode = $('#coupon_code').val() + if (couponCode.length === 0) { + return + } + $.ajax({ + type: 'PUT', + url: Spree.url(Spree.routes.apply_coupon_code(order_number)), + data: { + coupon_code: couponCode, + token: Spree.api_key + } + }).done(function () { + window.location.reload() + }).fail(function (message) { + if (message.responseJSON['error']) { + show_flash('error', message.responseJSON['error']) + } else { + show_flash('error', 'There was a problem adding this coupon code.') + } + }) + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/admin.js b/backend/app/assets/javascripts/spree/backend/admin.js new file mode 100644 index 00000000000..6c881852e9d --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/admin.js @@ -0,0 +1,391 @@ +/** +This is a collection of javascript functions and whatnot +under the spree namespace that do stuff we find helpful. +Hopefully, this will evolve into a propper class. +**/ + +/* global Cookies, AUTH_TOKEN, order_number */ + +jQuery(function ($) { + // Add some tips + $('.with-tip').tooltip() + + $('.js-show-index-filters').click(function () { + $('.filter-well').slideToggle() + $(this).parents('.filter-wrap').toggleClass('collapsed') + $('span.icon', $(this)).toggleClass('icon-chevron-down') + }) + + $('#main-sidebar').find('[data-toggle="collapse"]').on('click', function () { + if ($(this).find('.icon-chevron-left').length === 1) { + $(this).find('.icon-chevron-left').removeClass('icon-chevron-left').addClass('icon-chevron-down') + } else { + $(this).find('.icon-chevron-down').removeClass('icon-chevron-down').addClass('icon-chevron-left') + } + }) + + // Sidebar nav toggle functionality + $('#sidebar-toggle').on('click', function () { + var wrapper = $('#wrapper') + var main = $('#main-part') + var sidebar = $('#main-sidebar') + + // these should match `spree/backend/app/helpers/spree/admin/navigation_helper.rb#main_part_classes` + var mainWrapperCollapsedClasses = 'col-xs-12 sidebar-collapsed' + var mainWrapperExpandedClasses = 'col-xs-9 col-xs-offset-3 col-md-10 col-md-offset-2' + + wrapper.toggleClass('sidebar-minimized') + sidebar.toggleClass('hidden-xs') + main + .toggleClass(mainWrapperCollapsedClasses) + .toggleClass(mainWrapperExpandedClasses) + + if (wrapper.hasClass('sidebar-minimized')) { + Cookies.set('sidebar-minimized', 'true', { path: '/admin' }) + } else { + Cookies.set('sidebar-minimized', 'false', { path: '/admin' }) + } + }) + + $('.sidebar-menu-item').mouseover(function () { + if ($('#wrapper').hasClass('sidebar-minimized')) { + $(this).addClass('menu-active') + $(this).find('ul.nav').addClass('submenu-active') + } + }) + $('.sidebar-menu-item').mouseout(function () { + if ($('#wrapper').hasClass('sidebar-minimized')) { + $(this).removeClass('menu-active') + $(this).find('ul.nav').removeClass('submenu-active') + } + }) + + // TODO: remove this js temp behaviour and fix this decent + // Temp quick search + // When there was a search term, copy it + $('.js-quick-search').val($('.js-quick-search-target').val()) + // Catch the quick search form submit and submit the real form + $('#quick-search').submit(function () { + $('.js-quick-search-target').val($('.js-quick-search').val()) + $('#table-filter form').submit() + return false + }) + + // Main menu active item submenu show + var activeItem = $('#main-sidebar').find('.selected') + activeItem.closest('.nav-pills').addClass('in') + activeItem.closest('.nav-sidebar') + .find('.icon-chevron-left') + .removeClass('icon-chevron-left') + .addClass('icon-chevron-down') + + // Replace â–¼ and â–² in sort_link with nicer icons + $('.sort_link').each(function () { + // Remove the   in the text + var sortLinkText = $(this).text().replace('\xA0', '') + + if (sortLinkText.indexOf('â–¼') >= 0) { + $(this).text(sortLinkText.replace('â–¼', '')) + $(this).append('') + } else if (sortLinkText.indexOf('â–²') >= 0) { + $(this).text(sortLinkText.replace('â–²', '')) + $(this).append('') + } + }) + + // Clickable ransack filters + $('.js-add-filter').click(function () { + var ransackField = $(this).data('ransack-field') + var ransackValue = $(this).data('ransack-value') + + $('#' + ransackField).val(ransackValue) + $('#table-filter form').submit() + }) + + $(document).on('click', '.js-delete-filter', function () { + var ransackField = $(this).parents('.js-filter').data('ransack-field') + + $('#' + ransackField).val('') + $('#table-filter form').submit() + }) + + function ransackField (value) { + switch (value) { + case 'Date Range': + return 'Start' + case '': + return 'Stop' + default: + return value.trim() + } + } + + $('.js-filterable').each(function () { + var $this = $(this) + + if ($this.val()) { + var ransackValue, filter + var ransackFieldId = $this.attr('id') + var label = $('label[for="' + ransackFieldId + '"]') + + if ($this.is('select')) { + ransackValue = $this.find('option:selected').text() + } else { + ransackValue = $this.val() + } + + label = ransackField(label.text()) + ': ' + ransackValue + + filter = '' + label + '' + $('.js-filters').append(filter).show() + } + }) + + // per page dropdown + // preserves all selected filters / queries supplied by user + // changes only per_page value + $('.js-per-page-select').change(function () { + var form = $(this).closest('.js-per-page-form') + var url = form.attr('action') + var value = $(this).val().toString() + if (url.match(/\?/)) { + url += '&per_page=' + value + } else { + url += '?per_page=' + value + } + window.location = url + }) + + // injects per_page settings to all available search forms + // so when user changes some filters / queries per_page is preserved + $(document).ready(function () { + var perPageDropdown = $('.js-per-page-select:first') + if (perPageDropdown.length) { + var perPageValue = perPageDropdown.val().toString() + var perPageInput = '' + $('#table-filter form').append(perPageInput) + } + }) + + // Make flash messages disappear + setTimeout(function () { $('.alert-auto-disappear').slideUp() }, 5000) +}) + +$.fn.visible = function (cond) { this[ cond ? 'show' : 'hide' ]() } +// eslint-disable-next-line camelcase +function show_flash (type, message) { + var flashDiv = $('.alert-' + type) + if (flashDiv.length === 0) { + flashDiv = $('
    ') + $('#content').prepend(flashDiv) + } + flashDiv.html(message).show().delay(10000).slideUp() +} + +// Apply to individual radio button that makes another element visible when checked +$.fn.radioControlsVisibilityOfElement = function (dependentElementSelector) { + if (!this.get(0)) { return } + var showValue = this.get(0).value + var radioGroup = $("input[name='" + this.get(0).name + "']") + radioGroup.each(function () { + $(this).click(function () { + // eslint-disable-next-line eqeqeq + $(dependentElementSelector).visible(this.checked && this.value == showValue) + }) + if (this.checked) { this.click() } + }) +} +// eslint-disable-next-line camelcase +function handle_date_picker_fields () { + $('.datepicker').datepicker({ + dateFormat: Spree.translations.date_picker, + dayNames: Spree.translations.abbr_day_names, + dayNamesMin: Spree.translations.abbr_day_names, + firstDay: Spree.translations.first_day, + monthNames: Spree.translations.month_names, + prevText: Spree.translations.previous, + nextText: Spree.translations.next, + showOn: 'focus' + }) + + // Correctly display range dates + $('.date-range-filter .datepicker-from').datepicker('option', 'onSelect', function (selectedDate) { + $('.date-range-filter .datepicker-to').datepicker('option', 'minDate', selectedDate) + }) + $('.date-range-filter .datepicker-to').datepicker('option', 'onSelect', function (selectedDate) { + $('.date-range-filter .datepicker-from').datepicker('option', 'maxDate', selectedDate) + }) +} + +$(document).ready(function () { + handle_date_picker_fields() + $('.observe_field').on('change', function () { + var target = $(this).data('update') + $(target).hide() + $.ajax({ dataType: 'html', + url: $(this).data('base-url') + encodeURIComponent($(this).val()), + type: 'get', + success: function (data) { + $(target).html(data) + $(target).show() + } + }) + }) + + var uniqueId = 1 + $('.spree_add_fields').click(function () { + var target = $(this).data('target') + var newTableRow = $(target + ' tr:visible:last').clone() + var newId = new Date().getTime() + (uniqueId++) + newTableRow.find('input, select').each(function () { + var el = $(this) + el.val('') + el.prop('id', el.prop('id').replace(/\d+/, newId)) + el.prop('name', el.prop('name').replace(/\d+/, newId)) + }) + // When cloning a new row, set the href of all icons to be an empty "#" + // This is so that clicking on them does not perform the actions for the + // duplicated row + newTableRow.find('a').each(function () { + var el = $(this) + el.prop('href', '#') + }) + $(target).prepend(newTableRow) + }) + + $('body').on('click', '.delete-resource', function () { + var el = $(this) + if (confirm(el.data('confirm'))) { + $.ajax({ + type: 'POST', + url: $(this).prop('href'), + data: { + _method: 'delete', + authenticity_token: AUTH_TOKEN + }, + dataType: 'script', + success: function (response) { + var $flashElement = $('.alert-success') + if ($flashElement.length) { + el.parents('tr').fadeOut('hide', function () { + $(this).remove() + }) + } + }, + error: function (response, textStatus, errorThrown) { + show_flash('error', response.responseText) + } + }) + } + return false + }) + + $('body').on('click', 'a.spree_remove_fields', function () { + var el = $(this) + el.prev('input[type=hidden]').val('1') + el.closest('.fields').hide() + if (el.prop('href').substr(-1) === '#') { + el.parents('tr').fadeOut('hide') + } else if (el.prop('href')) { + $.ajax({ + type: 'POST', + url: el.prop('href'), + data: { + _method: 'delete', + authenticity_token: AUTH_TOKEN + }, + success: function (response) { + el.parents('tr').fadeOut('hide', function () { + $(this).remove() + }) + }, + error: function (response, textStatus, errorThrown) { + show_flash('error', response.responseText) + } + + }) + } + return false + }) + + $('body').on('click', '.select_properties_from_prototype', function () { + $('#busy_indicator').show() + var clickedLink = $(this) + $.ajax({ dataType: 'script', + url: clickedLink.prop('href'), + type: 'get', + success: function (data) { + clickedLink.parent('td').parent('tr').hide() + $('#busy_indicator').hide() + } + }) + return false + }) + + // Fix sortable helper + var fixHelper = function (e, ui) { + ui.children().each(function () { + $(this).width($(this).width()) + }) + return ui + } + + $('table.sortable').ready(function () { + var tdCount = $(this).find('tbody tr:first-child td').length + $('table.sortable tbody').sortable( + { + handle: '.handle', + helper: fixHelper, + placeholder: 'ui-sortable-placeholder', + update: function (event, ui) { + var tbody = this + $('#progress').show() + var positions = {} + $.each($('tr', tbody), function (position, obj) { + var reg = /spree_(\w+_?)+_(\d+)/ + var parts = reg.exec($(obj).prop('id')) + if (parts) { + positions['positions[' + parts[2] + ']'] = position + 1 + } + }) + $.ajax({ + type: 'POST', + dataType: 'script', + url: $(ui.item).closest('table.sortable').data('sortable-link'), + data: positions, + success: function (data) { $('#progress').hide() } + }) + }, + start: function (event, ui) { + // Set correct height for placehoder (from dragged tr) + ui.placeholder.height(ui.item.height()) + // Fix placeholder content to make it correct width + ui.placeholder.html("") + }, + stop: function (event, ui) { + // Fix odd/even classes after reorder + $('table.sortable tr:even').removeClass('odd even').addClass('even') + $('table.sortable tr:odd').removeClass('odd even').addClass('odd') + } + + }) + }) + + $('a.dismiss').click(function () { + $(this).parent().fadeOut() + }) + + window.Spree.advanceOrder = function () { + $.ajax({ + type: 'PUT', + async: false, + data: { + token: Spree.api_key + }, + // eslint-disable-next-line camelcase + url: Spree.url(Spree.routes.checkouts_api + '/' + order_number + '/advance') + }).done(function () { + window.location.reload() + }) + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/calculator.js b/backend/app/assets/javascripts/spree/backend/calculator.js new file mode 100644 index 00000000000..59e2a04d88c --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/calculator.js @@ -0,0 +1,17 @@ +$(function () { + var calculatorSelect = $('select#calc_type') + var originalCalcType = calculatorSelect.prop('value') + $('.calculator-settings-warning').hide() + calculatorSelect.change(function () { + // eslint-disable-next-line eqeqeq + if (calculatorSelect.prop('value') == originalCalcType) { + $('div.calculator-settings').show() + $('.calculator-settings-warning').hide() + $('.calculator-settings').find('input,textarea').prop('disabled', false) + } else { + $('div.calculator-settings').hide() + $('.calculator-settings-warning').show() + $('.calculator-settings').find('input,texttarea').prop('disabled', true) + } + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/checkouts/edit.js b/backend/app/assets/javascripts/spree/backend/checkouts/edit.js new file mode 100644 index 00000000000..1da6cfa850e --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/checkouts/edit.js @@ -0,0 +1,99 @@ +//= require_self +/* global customerTemplate, update_state */ +// eslint-disable-next-line camelcase +var clear_address_fields = function () { + var fields = ['firstname', 'lastname', 'company', 'address1', 'address2', 'city', 'zipcode', 'state_id', 'country_id', 'phone'] + $.each(fields, function (i, field) { + $('#order_bill_address_attributes_' + field).val('') + $('#order_ship_address_attributes_' + field).val('') + }) +} + +$(document).ready(function () { + if ($('#customer_autocomplete_template').length > 0) { + window.customerTemplate = Handlebars.compile($('#customer_autocomplete_template').text()) + } + + var formatCustomerResult = function (customer) { + return customerTemplate({ + customer: customer, + bill_address: customer.bill_address, + ship_address: customer.ship_address + }) + } + + if ($('#customer_search').length > 0) { + $('#customer_search').select2({ + placeholder: Spree.translations.choose_a_customer, + ajax: { + url: Spree.routes.users_api, + datatype: 'json', + cache: true, + data: function (term, page) { + return { + q: { + 'm': 'or', + 'email_start': term, + 'ship_address_firstname_start': term, + 'ship_address_lastname_start': term, + 'bill_address_firstname_start': term, + 'bill_address_lastname_start': term + }, + token: Spree.api_key + } + }, + results: function (data, page) { + return { results: data.users } + } + }, + dropdownCssClass: 'customer_search', + formatResult: formatCustomerResult, + formatSelection: function (customer) { + $('#order_email').val(customer.email) + $('#order_user_id').val(customer.id) + $('#guest_checkout_true').prop('checked', false) + $('#guest_checkout_false').prop('checked', true) + $('#guest_checkout_false').prop('disabled', false) + + var billAddress = customer.bill_address + if (billAddress) { + $('#order_bill_address_attributes_firstname').val(billAddress.firstname) + $('#order_bill_address_attributes_lastname').val(billAddress.lastname) + $('#order_bill_address_attributes_address1').val(billAddress.address1) + $('#order_bill_address_attributes_address2').val(billAddress.address2) + $('#order_bill_address_attributes_city').val(billAddress.city) + $('#order_bill_address_attributes_zipcode').val(billAddress.zipcode) + $('#order_bill_address_attributes_phone').val(billAddress.phone) + + $('#order_bill_address_attributes_country_id').select2('val', billAddress.country_id).promise().done(function () { + update_state('b', function () { + $('#order_bill_address_attributes_state_id').select2('val', billAddress.state_id) + }) + }) + } else { + clear_address_fields() + } + return Select2.util.escapeMarkup(customer.email) + } + }) + } + + var orderUseBillingInput = $('input#order_use_billing') + var orderUseBilling = function () { + if (!orderUseBillingInput.is(':checked')) { + $('#shipping').show() + } else { + $('#shipping').hide() + } + } + + orderUseBillingInput.click(orderUseBilling) + orderUseBilling() + + $('#guest_checkout_true').change(function () { + $('#customer_search').val('') + $('#order_user_id').val('') + $('#order_email').val('') + clear_address_fields() + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/gateway.js b/backend/app/assets/javascripts/spree/backend/gateway.js new file mode 100644 index 00000000000..b67eb85e53b --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/gateway.js @@ -0,0 +1,14 @@ +$(function () { + var originalGtwyType = $('#gtwy-type').prop('value') + $('div#gateway-settings-warning').hide() + $('#gtwy-type').change(function () { + // eslint-disable-next-line eqeqeq + if ($('#gtwy-type').prop('value') == originalGtwyType) { + $('div.gateway-settings').show() + $('div#gateway-settings-warning').hide() + } else { + $('div.gateway-settings').hide() + $('div#gateway-settings-warning').show() + } + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/general_settings.js b/backend/app/assets/javascripts/spree/backend/general_settings.js new file mode 100644 index 00000000000..ded842c789f --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/general_settings.js @@ -0,0 +1,18 @@ +/* global show_flash */ +$(function () { + $('[data-hook=general_settings_clear_cache] #clear_cache').click(function () { + if (confirm(Spree.translations.are_you_sure)) { + $.post(Spree.routes.clear_cache) + .done(function () { + show_flash('success', 'Cache was flushed.') + }) + .fail(function (message) { + if (message.responseJSON['error']) { + show_flash('error', message.responseJSON['error']) + } else { + show_flash('error', 'There was a problem while flushing cache.') + } + }) + } + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/handlebar_extensions.js b/backend/app/assets/javascripts/spree/backend/handlebar_extensions.js new file mode 100644 index 00000000000..f7267e7b7ad --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/handlebar_extensions.js @@ -0,0 +1,10 @@ +Handlebars.registerHelper('t', function (key) { + if (Spree.translations[key]) { + return Spree.translations[key] + } else { + console.error('No translation found for ' + key + '. Does it exist within spree/admin/shared/_translations.html.erb?') + } +}) +Handlebars.registerHelper('edit_product_url', function (productId) { + return Spree.routes.edit_product(productId) +}) diff --git a/backend/app/assets/javascripts/spree/backend/line_items.js b/backend/app/assets/javascripts/spree/backend/line_items.js new file mode 100644 index 00000000000..d8bef32b6b9 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/line_items.js @@ -0,0 +1,72 @@ +/* global toggleItemEdit, order_number */ +$(function () { + // handle edit click + $('a.edit-line-item').click(toggleLineItemEdit) + // handle cancel click + $('a.cancel-line-item').click(toggleLineItemEdit) + // handle save click + $('a.save-line-item').click(function () { + var save = $(this) + var lineItemId = save.data('line-item-id') + var quantity = parseInt(save.parents('tr').find('input.line_item_quantity').val()) + toggleItemEdit() + adjustLineItem(lineItemId, quantity) + }) + // handle delete click + $('a.delete-line-item').click(function () { + if (confirm(Spree.translations.are_you_sure_delete)) { + var del = $(this) + var lineItemId = del.data('line-item-id') + toggleItemEdit() + deleteLineItem(lineItemId) + } + }) +}) + +function toggleLineItemEdit () { + var link = $(this) + var parent = link.parent() + var tr = link.parents('tr') + parent.find('a.edit-line-item').toggle() + parent.find('a.cancel-line-item').toggle() + parent.find('a.save-line-item').toggle() + parent.find('a.delete-line-item').toggle() + tr.find('td.line-item-qty-show').toggle() + tr.find('td.line-item-qty-edit').toggle() +} + +function lineItemURL (lineItemId) { + // eslint-disable-next-line camelcase + return Spree.routes.orders_api + '/' + order_number + '/line_items/' + lineItemId + '.json' +} + +function adjustLineItem (lineItemId, quantity) { + $.ajax({ + type: 'PUT', + url: Spree.url(lineItemURL(lineItemId)), + data: { + line_item: { + quantity: quantity + }, + token: Spree.api_key + } + }).done(function () { + window.Spree.advanceOrder() + }) +} + +function deleteLineItem (lineItemId) { + $.ajax({ + type: 'DELETE', + url: Spree.url(lineItemURL(lineItemId)), + headers: { + 'X-Spree-Token': Spree.api_key + } + }).done(function () { + $('#line-item-' + lineItemId).remove() + if ($('.line-items tr.line-item').length === 0) { + $('.line-items').remove() + } + window.Spree.advanceOrder() + }) +} diff --git a/backend/app/assets/javascripts/spree/backend/line_items_on_order_edit.js b/backend/app/assets/javascripts/spree/backend/line_items_on_order_edit.js new file mode 100644 index 00000000000..f5d2e630c59 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/line_items_on_order_edit.js @@ -0,0 +1,53 @@ +/* global variantLineItemTemplate, order_number */ +// This file contains the code for interacting with line items in the manual cart +$(document).ready(function () { + 'use strict' + + // handle variant selection, show stock level. + $('#add_line_item_variant_id').change(function () { + var variantId = $(this).val() + + var variant = _.find(window.variants, function (variant) { + // eslint-disable-next-line eqeqeq + return variant.id == variantId + }) + $('#stock_details').html(variantLineItemTemplate({ variant: variant })) + $('#stock_details').show() + $('button.add_variant').click(addVariant) + }) +}) + +function addVariant () { + $('#stock_details').hide() + var variantId = $('input.variant_autocomplete').val() + var quantity = $("input.quantity[data-variant-id='" + variantId + "']").val() + + adjustLineItems(order_number, variantId, quantity) + return 1 +} + +function adjustLineItems (orderNumber, variantId, quantity) { + var url = Spree.routes.orders_api + '/' + orderNumber + '/line_items' + + $.ajax({ + type: 'POST', + url: Spree.url(url), + data: { + line_item: { + variant_id: variantId, + quantity: quantity + }, + token: Spree.api_key + } + }).done(function (msg) { + window.Spree.advanceOrder() + window.location.reload() + }).fail(function (msg) { + // eslint-disable-next-line eqeqeq + if (typeof msg.responseJSON.message != 'undefined') { + alert(msg.responseJSON.message) + } else { + alert(msg.responseJSON.exception) + } + }) +} diff --git a/backend/app/assets/javascripts/spree/backend/option_type_autocomplete.js b/backend/app/assets/javascripts/spree/backend/option_type_autocomplete.js new file mode 100644 index 00000000000..88b6ecf5daf --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/option_type_autocomplete.js @@ -0,0 +1,43 @@ +$(document).ready(function () { + 'use strict' + + function formatOptionType (optionType) { + return Select2.util.escapeMarkup(optionType.presentation + ' (' + optionType.name + ')') + } + + if ($('#product_option_type_ids').length > 0) { + $('#product_option_type_ids').select2({ + placeholder: Spree.translations.option_type_placeholder, + multiple: true, + initSelection: function (element, callback) { + var url = Spree.url(Spree.routes.option_types_api, { + ids: element.val(), + token: Spree.api_key + }) + return $.getJSON(url, null, function (data) { + return callback(data) + }) + }, + ajax: { + url: Spree.routes.option_types_api, + quietMillis: 200, + datatype: 'json', + data: function (term) { + return { + q: { + name_cont: term + }, + token: Spree.api_key + } + }, + results: function (data) { + return { + results: data + } + } + }, + formatResult: formatOptionType, + formatSelection: formatOptionType + }) + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/option_value_picker.js b/backend/app/assets/javascripts/spree/backend/option_value_picker.js new file mode 100644 index 00000000000..0afe1c0c0ce --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/option_value_picker.js @@ -0,0 +1,44 @@ +$.fn.optionValueAutocomplete = function (options) { + 'use strict' + + // Default options + options = options || {} + var multiple = typeof (options.multiple) !== 'undefined' ? options.multiple : true + var productSelect = options.productSelect + + this.select2({ + minimumInputLength: 3, + multiple: multiple, + initSelection: function (element, callback) { + $.get(Spree.routes.option_values_api, { + ids: element.val().split(','), + token: Spree.api_key + }, function (data) { + callback(multiple ? data : data[0]) + }) + }, + ajax: { + url: Spree.routes.option_values_api, + datatype: 'json', + data: function (term) { + var productId = typeof (productSelect) !== 'undefined' ? $(productSelect).select2('val') : null + return { + q: { + name_cont: term, + variants_product_id_eq: productId + }, + token: Spree.api_key + } + }, + results: function (data) { + return { results: data } + } + }, + formatResult: function (optionValue) { + return optionValue.name + }, + formatSelection: function (optionValue) { + return optionValue.name + } + }) +} diff --git a/backend/app/assets/javascripts/spree/backend/orders/edit.js b/backend/app/assets/javascripts/spree/backend/orders/edit.js new file mode 100644 index 00000000000..8264afc5a49 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/orders/edit.js @@ -0,0 +1,4 @@ +$(document).ready(function () { + 'use strict' + $('[data-hook="add_product_name"]').find('.variant_autocomplete').variantAutocomplete() +}) diff --git a/backend/app/assets/javascripts/spree/backend/payments/edit.js b/backend/app/assets/javascripts/spree/backend/payments/edit.js new file mode 100644 index 00000000000..dbbc30a9421 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/payments/edit.js @@ -0,0 +1,230 @@ +/* global show_flash */ +jQuery(function ($) { + var extend = function (child, parent) { + for (var key in parent) { + if (hasProp.call(parent, key)) child[key] = parent[key] + } + function Ctor () { + this.constructor = child + } + Ctor.prototype = parent.prototype + child.prototype = new Ctor() + child.__super__ = parent.prototype + return child + } + var hasProp = {}.hasOwnProperty + var EditPaymentView, Payment, PaymentView, ShowPaymentView, orderId + orderId = $('#payments').data('order-id') + Payment = (function () { + function Payment (number) { + this.url = Spree.url(((Spree.routes.payments_api(orderId)) + '/' + number + '.json') + '?token=' + Spree.api_key) + this.json = $.getJSON(this.url.toString(), function (data) { + this.data = data + }.bind(this)) + this.updating = false + } + + Payment.prototype.if_editable = function (callback) { + return this.json.done(function (data) { + var ref + if ((ref = data.state) === 'checkout' || ref === 'pending') { + return callback() + } + }) + } + + Payment.prototype.update = function (attributes) { + this.updating = true + var jqXHR = $.ajax({ + type: 'PUT', + url: this.url.toString(), + data: { + payment: attributes + } + }) + jqXHR.always(function () { + this.updating = false + }.bind(this)) + jqXHR.done(function (data) { + this.data = data + }.bind(this)) + jqXHR.fail(function () { + var response = (jqXHR.responseJSON && jqXHR.responseJSON.error) || jqXHR.statusText + show_flash('error', response) + }) + return jqXHR + } + + Payment.prototype.amount = function () { + return this.data.amount + } + + Payment.prototype.display_amount = function () { + return this.data.display_amount + } + return Payment + })() + + PaymentView = (function () { + function PaymentView ($el1, payment1) { + this.$el = $el1 + this.payment = payment1 + this.render() + } + + PaymentView.prototype.render = function () { + return this.add_action_button() + } + + PaymentView.prototype.show = function () { + this.remove_buttons() + return new ShowPaymentView(this.$el, this.payment) + } + + PaymentView.prototype.edit = function () { + this.remove_buttons() + return new EditPaymentView(this.$el, this.payment) + } + + PaymentView.prototype.add_action_button = function () { + return this.$actions().prepend(this.$new_button(this.action)) + } + + PaymentView.prototype.remove_buttons = function () { + return this.$buttons().remove() + } + + PaymentView.prototype.$new_button = function (action) { + return $('').attr({ + 'class': 'payment-action-' + action + ' btn btn-default btn-sm icon-link no-text with-tip', + title: Spree.translations[action] + }).data({ + action: action + }).one({ + click: function (event) { + event.preventDefault() + }, + mouseup: function () { + this[action]() + }.bind(this) + }) + } + + PaymentView.prototype.$buttons = function () { + return this.$actions().find('.payment-action-' + this.action + ', .payment-action-cancel') + } + + PaymentView.prototype.$actions = function () { + return this.$el.find('.actions') + } + + PaymentView.prototype.$amount = function () { + return this.$el.find('td.amount') + } + + return PaymentView + })() + ShowPaymentView = (function (superClass) { + extend(ShowPaymentView, superClass) + + function ShowPaymentView () { + return ShowPaymentView.__super__.constructor.apply(this, arguments) + } + + ShowPaymentView.prototype.action = 'edit' + + ShowPaymentView.prototype.render = function () { + ShowPaymentView.__super__.render.apply(this, arguments) + this.set_actions_display() + this.show_actions() + return this.show_amount() + } + + ShowPaymentView.prototype.set_actions_display = function () { + var width = this.$actions().width() + return this.$actions().width(width).css('text-align', 'left') + } + + ShowPaymentView.prototype.show_actions = function () { + return this.$actions().find('a').show() + } + + ShowPaymentView.prototype.show_amount = function () { + var amount = $('').html(this.payment.display_amount()).one('click', function () { + this.edit().$input().focus() + }.bind(this)) + return this.$amount().html(amount) + } + + return ShowPaymentView + })(PaymentView) + EditPaymentView = (function (superClass) { + extend(EditPaymentView, superClass) + + function EditPaymentView () { + return EditPaymentView.__super__.constructor.apply(this, arguments) + } + + EditPaymentView.prototype.action = 'save' + + EditPaymentView.prototype.render = function () { + EditPaymentView.__super__.render.apply(this, arguments) + this.hide_actions() + this.edit_amount() + return this.add_cancel_button() + } + + EditPaymentView.prototype.add_cancel_button = function () { + return this.$actions().append(this.$new_button('cancel')) + } + + EditPaymentView.prototype.hide_actions = function () { + return this.$actions().find('a').not(this.$buttons()).hide() + } + + EditPaymentView.prototype.edit_amount = function () { + var amount = this.$amount() + return amount.html(this.$new_input(amount.find('span').width())) + } + + EditPaymentView.prototype.save = function () { + if (!this.payment.updating) { + return this.payment.update({ + amount: this.$input().val() + }).done(function () { + return this.show() + }.bind(this)) + } + } + + EditPaymentView.prototype.cancel = EditPaymentView.prototype.show + + EditPaymentView.prototype.$new_input = function (width) { + var amount = this.constructor.normalize_amount(this.payment.display_amount()) + return $('').prop({ + id: 'amount', + value: amount + }).width(width).css({ + 'text-align': 'right' + }) + } + + EditPaymentView.prototype.$input = function () { + return this.$amount().find('input') + } + + EditPaymentView.normalize_amount = function (amount) { + var separator = Spree.translations.currency_separator + return amount.replace(RegExp('[^\\d' + separator + ']', 'g'), '') + } + + return EditPaymentView + })(PaymentView) + return $('.admin tr[data-hook=payments_row]').each(function () { + var $el = $(this) + var payment = new Payment($el.attr('data-number')) + return payment.if_editable(function () { + return new ShowPaymentView($el, payment) + }) + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/payments/new.js b/backend/app/assets/javascripts/spree/backend/payments/new.js new file mode 100644 index 00000000000..ba2ae409be1 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/payments/new.js @@ -0,0 +1,50 @@ +//= require jquery.payment +$(document).ready(function () { + if ($('#new_payment').length) { + $('.cardNumber').payment('formatCardNumber') + $('.cardExpiry').payment('formatCardExpiry') + $('.cardCode').payment('formatCardCVC') + + $('.cardNumber').change(function () { + $('.ccType').val($.payment.cardType(this.value)) + }) + + $('.payment_methods_radios').click( + function () { + $('.payment-methods').hide() + $('.payment-methods :input').prop('disabled', true) + if (this.checked) { + $('#payment_method_' + this.value + ' :input').prop('disabled', false) + $('#payment_method_' + this.value).show() + } + } + ) + + $('.payment_methods_radios').each( + function () { + if (this.checked) { + $('#payment_method_' + this.value + ' :input').prop('disabled', false) + $('#payment_method_' + this.value).show() + } else { + $('#payment_method_' + this.value).hide() + $('#payment_method_' + this.value + ' :input').prop('disabled', true) + } + + if ($('#card_new' + this.value).is('*')) { + $('#card_new' + this.value).radioControlsVisibilityOfElement('#card_form' + this.value) + } + } + ) + + $('.cvvLink').click(function (event) { + var windowName = 'cvv_info' + var windowOptions = 'left=20,top=20,width=500,height=500,toolbar=0,resizable=0,scrollbars=1' + window.open($(this).prop('href'), windowName, windowOptions) + event.preventDefault() + }) + + $('select.jump_menu').change(function () { + window.location = this.options[this.selectedIndex].value + }) + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/product_picker.js b/backend/app/assets/javascripts/spree/backend/product_picker.js new file mode 100644 index 00000000000..a18766ed0f6 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/product_picker.js @@ -0,0 +1,50 @@ +$.fn.productAutocomplete = function (options) { + 'use strict' + + // Default options + options = options || {} + var multiple = typeof (options.multiple) !== 'undefined' ? options.multiple : true + + function formatProduct (product) { + return Select2.util.escapeMarkup(product.name) + } + + this.select2({ + minimumInputLength: 3, + multiple: multiple, + initSelection: function (element, callback) { + $.get(Spree.routes.products_api, { + ids: element.val().split(','), + token: Spree.api_key + }, function (data) { + callback(multiple ? data.products : data.products[0]) + }) + }, + ajax: { + url: Spree.routes.products_api, + datatype: 'json', + cache: true, + data: function (term, page) { + return { + q: { + name_or_master_sku_cont: term + }, + m: 'OR', + token: Spree.api_key + } + }, + results: function (data, page) { + var products = data.products ? data.products : [] + return { + results: products + } + } + }, + formatResult: formatProduct, + formatSelection: formatProduct + }) +} + +$(document).ready(function () { + $('.product_picker').productAutocomplete() +}) diff --git a/backend/app/assets/javascripts/spree/backend/progress.js b/backend/app/assets/javascripts/spree/backend/progress.js new file mode 100644 index 00000000000..182c9cb59bc --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/progress.js @@ -0,0 +1,8 @@ +$(function () { + $(document).ajaxStart(function () { + $('#progress').stop(true, true).fadeIn() + }) + $(document).ajaxStop(function () { + $('#progress').fadeOut() + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/promotions.js b/backend/app/assets/javascripts/spree/backend/promotions.js new file mode 100644 index 00000000000..d0fce9c3b77 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/promotions.js @@ -0,0 +1,165 @@ +function initProductActions () { + 'use strict' + + // Add classes on promotion items for design + $(document).on('mouseover mouseout', 'a.delete', function (event) { + if (event.type === 'mouseover') { + $(this).parent().addClass('action-remove') + } else { + $(this).parent().removeClass('action-remove') + } + }) + + $('#promotion-filters').find('.variant_autocomplete').variantAutocomplete() + + $('.calculator-fields').each(function () { + var $fieldsContainer = $(this) + var $typeSelect = $fieldsContainer.find('.type-select') + var $settings = $fieldsContainer.find('.settings') + var $warning = $fieldsContainer.find('.js-warning') + var originalType = $typeSelect.val() + + $warning.hide() + $typeSelect.change(function () { + if ($(this).val() === originalType) { + $warning.hide() + $settings.show() + $settings.find('input').removeProp('disabled') + } else { + $warning.show() + $settings.hide() + $settings.find('input').prop('disabled', 'disabled') + } + }) + }) + + // + // Option Value Promo Rule + // + if ($('#promo-rule-option-value-template').length) { + var optionValueSelectNameTemplate = Handlebars.compile($('#promo-rule-option-value-option-values-select-name-template').html()) + var optionValueTemplate = Handlebars.compile($('#promo-rule-option-value-template').html()) + + var addOptionValue = function (product, values) { + $('.js-promo-rule-option-values').append(optionValueTemplate({ + productSelect: { value: product }, + optionValuesSelect: { value: values } + })) + var optionValue = $('.js-promo-rule-option-values .promo-rule-option-value').last() + optionValue.find('.js-promo-rule-option-value-product-select').productAutocomplete({ multiple: false }) + optionValue.find('.js-promo-rule-option-value-option-values-select').optionValueAutocomplete({ + productSelect: '.js-promo-rule-option-value-product-select' + }) + if (product === null) { + optionValue.find('.js-promo-rule-option-value-option-values-select').prop('disabled', true) + } + } + + var originalOptionValues = $('.js-original-promo-rule-option-values').data('original-option-values') + if (!$('.js-original-promo-rule-option-values').data('loaded')) { + if ($.isEmptyObject(originalOptionValues)) { + addOptionValue(null, null) + } else { + $.each(originalOptionValues, addOptionValue) + } + } + $('.js-original-promo-rule-option-values').data('loaded', true) + + $(document).on('click', '.js-add-promo-rule-option-value', function (event) { + event.preventDefault() + addOptionValue(null, null) + }) + + $(document).on('click', '.js-remove-promo-rule-option-value', function () { + $(this).parents('.promo-rule-option-value').remove() + }) + + $(document).on('change', '.js-promo-rule-option-value-product-select', function () { + var optionValueSelect = $(this).parents('.promo-rule-option-value').find('.js-promo-rule-option-value-option-values-select') + optionValueSelect.attr('name', optionValueSelectNameTemplate({ productId: $(this).val() }).trim()) + optionValueSelect.prop('disabled', $(this).val() === '').select2('val', '') + }) + } + + // + // Tiered Calculator + // + if ($('#tier-fields-template').length && $('#tier-input-name').length) { + var tierFieldsTemplate = Handlebars.compile($('#tier-fields-template').html()) + var tierInputNameTemplate = Handlebars.compile($('#tier-input-name').html()) + + var originalTiers = $('.js-original-tiers').data('original-tiers') + $.each(originalTiers, function (base, value) { + var fieldName = tierInputNameTemplate({ base: base }).trim() + $('.js-tiers').append(tierFieldsTemplate({ + baseField: { value: base }, + valueField: { name: fieldName, value: value } + })) + }) + + $(document).on('click', '.js-add-tier', function (event) { + event.preventDefault() + $('.js-tiers').append(tierFieldsTemplate({ valueField: { name: null } })) + }) + + $(document).on('click', '.js-remove-tier', function (event) { + $(this).parents('.tier').remove() + }) + + $(document).on('change', '.js-base-input', function (event) { + var valueInput = $(this).parents('.tier').find('.js-value-input') + valueInput.attr('name', tierInputNameTemplate({ base: $(this).val() }).trim()) + }) + } + + // + // CreateLineItems Promotion Action + // + (function () { + function hideOrShowItemTables () { + $('.promotion_action table').each(function () { + if ($(this).find('td').length === 0) { + $(this).hide() + } else { + $(this).show() + } + }) + } + hideOrShowItemTables() + + // Remove line item + function setupRemoveLineItems () { + $('.remove_promotion_line_item').on('click', function () { + var lineItemsEl = $($('.line_items_string')[0]) + var finder = new RegExp($(this).data('variant-id') + 'x\\d+') + lineItemsEl.val(lineItemsEl.val().replace(finder, '')) + $(this).parents('tr').remove() + hideOrShowItemTables() + }) + } + + setupRemoveLineItems() + // Add line item to list + $('.promotion_action.create_line_items button.add').unbind('click').click(function () { + var $container = $(this).parents('.promotion_action') + var productName = $container.find('input[name="add_product_name"]').val() + var variantId = $container.find('input[name="add_variant_id"]').val() + var quantity = $container.find('input[name="add_quantity"]').val() + if (variantId) { + // Add to the table + var newRow = '' + productName + '' + quantity + '' + $container.find('table').append(newRow) + // Add to serialized string in hidden text field + var $hiddenField = $container.find('.line_items_string') + $hiddenField.val($hiddenField.val() + ',' + variantId + 'x' + quantity) + setupRemoveLineItems() + hideOrShowItemTables() + } + return false + }) + })() +} + +$(document).ready(function () { + initProductActions() +}) diff --git a/backend/app/assets/javascripts/spree/backend/returns/expedited_exchanges_warning.js b/backend/app/assets/javascripts/spree/backend/returns/expedited_exchanges_warning.js new file mode 100644 index 00000000000..b9acc70f818 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/returns/expedited_exchanges_warning.js @@ -0,0 +1,5 @@ +$(function () { + $(document).on('change', '.return-items-table .return-item-exchange-selection', function () { + $('.expedited-exchanges-warning').fadeIn() + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/returns/return_item_selection.js b/backend/app/assets/javascripts/spree/backend/returns/return_item_selection.js new file mode 100644 index 00000000000..6e44aacb289 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/returns/return_item_selection.js @@ -0,0 +1,40 @@ +$(document).ready(function () { + var formFields = $("[data-hook='admin_customer_return_form_fields'], [data-hook='admin_return_authorization_form_fields']") + + function checkAddItemBox () { + $(this).closest('tr').find('input.add-item').attr('checked', 'checked') + updateSuggestedAmount() + } + + function updateSuggestedAmount () { + var totalPretaxRefund = 0 + var checkedItems = formFields.find('input.add-item:checked') + $.each(checkedItems, function (i, checkbox) { + var returnItemRow = $(checkbox).parents('tr') + var returnQuantity = parseInt(returnItemRow.find('.refund-quantity-input').val(), 10) + var purchasedQuantity = parseInt(returnItemRow.find('.purchased-quantity').text(), 10) + var amount = (returnQuantity / purchasedQuantity) * parseFloat(returnItemRow.find('.charged-amount').data('chargedAmount')) + returnItemRow.find('.refund-amount-input').val(amount.toFixed(2)) + totalPretaxRefund += amount + }) + + var displayTotal = isNaN(totalPretaxRefund) ? '' : totalPretaxRefund.toFixed(2) + formFields.find('span#total_pre_tax_refund').html(displayTotal) + } + + if (formFields.length > 0) { + updateSuggestedAmount() + + formFields.find('input#select-all').on('change', function (ev) { + var checkBoxes = $(ev.currentTarget).parents('table:first').find('input.add-item') + checkBoxes.prop('checked', this.checked) + updateSuggestedAmount() + }) + + formFields.find('input.add-item').on('change', updateSuggestedAmount) + formFields.find('.refund-amount-input').on('keyup', updateSuggestedAmount) + formFields.find('.refund-quantity-input').on('keyup mouseup', updateSuggestedAmount) + + formFields.find('input, select').not('.add-item').on('change', checkAddItemBox) + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/shipments.js b/backend/app/assets/javascripts/spree/backend/shipments.js new file mode 100644 index 00000000000..4b00bbb945c --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/shipments.js @@ -0,0 +1,377 @@ +/* global shipments, variantStockTemplate, order_number */ +// Shipments AJAX API +$(document).ready(function () { + 'use strict' + + // handle variant selection, show stock level. + $('#add_variant_id').change(function () { + var variantId = $(this).val() + + var variant = _.find(window.variants, function (variant) { + // eslint-disable-next-line eqeqeq + return variant.id == variantId + }) + $('#stock_details').html(variantStockTemplate({ variant: variant })) + $('#stock_details').show() + + $('button.add_variant').click(addVariantFromStockLocation) + }) + + // handle edit click + $('a.edit-item').click(toggleItemEdit) + + // handle cancel click + $('a.cancel-item').click(toggleItemEdit) + + // handle split click + $('a.split-item').click(startItemSplit) + + // handle save click + $('a.save-item').click(function () { + var save = $(this) + var shipmentNumber = save.data('shipment-number') + var variantId = save.data('variant-id') + + var quantity = parseInt(save.parents('tr').find('input.line_item_quantity').val()) + + toggleItemEdit() + adjustShipmentItems(shipmentNumber, variantId, quantity) + return false + }) + + // handle delete click + $('a.delete-item').click(function (event) { + if (confirm(Spree.translations.are_you_sure_delete)) { + var del = $(this) + var shipmentNumber = del.data('shipment-number') + var variantId = del.data('variant-id') + // eslint-disable-next-line + var shipment = _.findWhere(shipments, { number: shipmentNumber + '' }) + var url = Spree.routes.shipments_api + '/' + shipmentNumber + '/remove' + + toggleItemEdit() + + $.ajax({ + type: 'PUT', + url: Spree.url(url), + data: { + variant_id: variantId, + token: Spree.api_key + } + }).done(function (msg) { + window.location.reload() + }).fail(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + } + return false + }) + + // handle ship click + $('[data-hook=admin_shipment_form] a.ship').on('click', function () { + var link = $(this) + var shipmentNumber = link.data('shipment-number') + var url = Spree.url(Spree.routes.shipments_api + '/' + shipmentNumber + '/ship.json') + $.ajax({ + type: 'PUT', + url: url, + data: { + token: Spree.api_key + } + }).done(function () { + window.location.reload() + }).error(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + }) + + // handle shipping method edit click + $('a.edit-method').click(toggleMethodEdit) + $('a.cancel-method').click(toggleMethodEdit) + + // handle shipping method save + $('[data-hook=admin_shipment_form] a.save-method').on('click', function (event) { + event.preventDefault() + + var link = $(this) + var shipmentNumber = link.data('shipment-number') + var selectedShippingRateId = link.parents('tbody').find("select#selected_shipping_rate_id[data-shipment-number='" + shipmentNumber + "']").val() + var unlock = link.parents('tbody').find("input[name='open_adjustment'][data-shipment-number='" + shipmentNumber + "']:checked").val() + var url = Spree.url(Spree.routes.shipments_api + '/' + shipmentNumber + '.json') + + $.ajax({ + type: 'PUT', + url: url, + data: { + shipment: { + selected_shipping_rate_id: selectedShippingRateId, + unlock: unlock + }, + token: Spree.api_key + } + }).done(function () { + window.location.reload() + }).error(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + }) + + function toggleTrackingEdit (event) { + event.preventDefault() + + var link = $(this) + link.parents('tbody').find('tr.edit-tracking').toggle() + link.parents('tbody').find('tr.show-tracking').toggle() + } + + // handle tracking edit click + $('a.edit-tracking').click(toggleTrackingEdit) + $('a.cancel-tracking').click(toggleTrackingEdit) + + function createTrackingValueContent (data) { + var selectedShippingMethod = data.shipping_methods.filter(function (method) { + return method.id === data.selected_shipping_rate.shipping_method_id + })[0] + + if (selectedShippingMethod && selectedShippingMethod.tracking_url) { + var shipmentTrackingUrl = selectedShippingMethod.tracking_url.replace(/:tracking/, data.tracking) + return '' + data.tracking + '' + } + + return data.tracking + } + + // handle tracking save + $('[data-hook=admin_shipment_form] a.save-tracking').on('click', function (event) { + event.preventDefault() + + var link = $(this) + var shipmentNumber = link.data('shipment-number') + var tracking = link.parents('tbody').find('input#tracking').val() + var url = Spree.url(Spree.routes.shipments_api + '/' + shipmentNumber + '.json') + + $.ajax({ + type: 'PUT', + url: url, + data: { + shipment: { + tracking: tracking + }, + token: Spree.api_key + } + }).done(function (data) { + link.parents('tbody').find('tr.edit-tracking').toggle() + + var show = link.parents('tbody').find('tr.show-tracking') + show.toggle() + + if (data.tracking) { + show.find('.tracking-value').html($('').html(Spree.translations.tracking + ': ')).append(createTrackingValueContent(data)) + } else { + show.find('.tracking-value').html(Spree.translations.no_tracking_present) + } + }) + }) +}) + +function adjustShipmentItems (shipmentNumber, variantId, quantity) { + var shipment = _.findWhere(shipments, { number: shipmentNumber + '' }) + var inventoryUnits = _.where(shipment.inventory_units, { variant_id: variantId }) + var url = Spree.routes.shipments_api + '/' + shipmentNumber + var previousQuantity = inventoryUnits.reduce(function (accumulator, currentUnit, _index, _array) { + return accumulator + currentUnit.quantity + }, 0) + var newQuantity = 0 + + if (previousQuantity < quantity) { + url += '/add' + newQuantity = (quantity - previousQuantity) + } else if (previousQuantity > quantity) { + url += '/remove' + newQuantity = (previousQuantity - quantity) + } + url += '.json' + + if (newQuantity !== 0) { + $.ajax({ + type: 'PUT', + url: Spree.url(url), + data: { + variant_id: variantId, + quantity: newQuantity, + token: Spree.api_key + } + }).done(function (msg) { + window.location.reload() + }).fail(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + } +} + +function toggleMethodEdit () { + var link = $(this) + link.parents('tbody').find('tr.edit-method').toggle() + link.parents('tbody').find('tr.show-method').toggle() + + return false +} + +function toggleItemEdit () { + var link = $(this) + var linkParent = link.parent() + linkParent.find('a.edit-item').toggle() + linkParent.find('a.cancel-item').toggle() + linkParent.find('a.split-item').toggle() + linkParent.find('a.save-item').toggle() + linkParent.find('a.delete-item').toggle() + link.parents('tr').find('td.item-qty-show').toggle() + link.parents('tr').find('td.item-qty-edit').toggle() + + return false +} + +function startItemSplit (event) { + event.preventDefault() + $('.cancel-split').each(function () { + $(this).click() + }) + var link = $(this) + link.parent().find('a.edit-item').toggle() + link.parent().find('a.split-item').toggle() + link.parent().find('a.delete-item').toggle() + var variantId = link.data('variant-id') + + var variant = {} + $.ajax({ + type: 'GET', + async: false, + url: Spree.url(Spree.routes.variants_api), + data: { + q: { + 'id_eq': variantId + }, + token: Spree.api_key + } + }).success(function (data) { + variant = data['variants'][0] + }).error(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + + var maxQuantity = link.closest('tr').data('item-quantity') + var splitItemTemplate = Handlebars.compile($('#variant_split_template').text()) + link.closest('tr').after(splitItemTemplate({ variant: variant, shipments: shipments, max_quantity: maxQuantity })) + $('a.cancel-split').click(cancelItemSplit) + $('a.save-split').click(completeItemSplit) + + $('#item_stock_location').select2({ width: 'resolve', placeholder: Spree.translations.item_stock_placeholder }) +} + +function completeItemSplit (event) { + event.preventDefault() + + if ($('#item_stock_location').val() === '') { + alert('Please select the split destination.') + return false + } + + var link = $(this) + var stockItemRow = link.closest('tr') + var variantId = stockItemRow.data('variant-id') + var quantity = stockItemRow.find('#item_quantity').val() + + var stockLocationId = stockItemRow.find('#item_stock_location').val() + var originalShipmentNumber = link.closest('tbody').data('shipment-number') + + var selectedShipment = stockItemRow.find($('#item_stock_location').select2('data').element) + var targetShipmentNumber = selectedShipment.data('shipment-number') + var newShipment = selectedShipment.data('new-shipment') + // eslint-disable-next-line + if (stockLocationId !== 'new_shipment') { + var splitItems = function (opts) { + $.ajax({ + type: 'POST', + async: false, + url: opts.url, + data: opts.data + }).error(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }).done(function () { + window.location.reload() + }) + } + + if (newShipment !== undefined) { + // TRANSFER TO A NEW LOCATION + splitItems({ + url: Spree.url(Spree.routes.shipments_api + '/transfer_to_location'), + data: { + original_shipment_number: originalShipmentNumber, + variant_id: variantId, + quantity: quantity, + stock_location_id: stockLocationId, + token: Spree.api_key + } + }) + } else { + // TRANSFER TO AN EXISTING SHIPMENT + splitItems({ + url: Spree.url(Spree.routes.shipments_api + '/transfer_to_shipment'), + data: { + original_shipment_number: originalShipmentNumber, + target_shipment_number: targetShipmentNumber, + variant_id: variantId, + quantity: quantity, + token: Spree.api_key + } + }) + } + } +} + +function cancelItemSplit (event) { + event.preventDefault() + var link = $(this) + var prevRow = link.closest('tr').prev() + link.closest('tr').remove() + prevRow.find('a.edit-item').toggle() + prevRow.find('a.split-item').toggle() + prevRow.find('a.delete-item').toggle() +} + +function addVariantFromStockLocation (event) { + event.preventDefault() + + $('#stock_details').hide() + + var variantId = $('input.variant_autocomplete').val() + var stockLocationId = $(this).data('stock-location-id') + var quantity = $("input.quantity[data-stock-location-id='" + stockLocationId + "']").val() + + var shipment = _.find(shipments, function (shipment) { + return shipment.stock_location_id === stockLocationId && (shipment.state === 'ready' || shipment.state === 'pending') + }) + + if (shipment === undefined) { + $.ajax({ + type: 'POST', + // eslint-disable-next-line camelcase + url: Spree.url(Spree.routes.shipments_api + '?shipment[order_id]=' + order_number), + data: { + variant_id: variantId, + quantity: quantity, + stock_location_id: stockLocationId, + token: Spree.api_key + } + }).done(function (msg) { + window.location.reload() + }).error(function (msg) { + alert(msg.responseJSON.message || msg.responseJSON.exception) + }) + } else { + // add to existing shipment + adjustShipmentItems(shipment.number, variantId, quantity) + } + return 1 +} diff --git a/backend/app/assets/javascripts/spree/backend/spree-select2.js b/backend/app/assets/javascripts/spree/backend/spree-select2.js new file mode 100644 index 00000000000..8543cc3c487 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/spree-select2.js @@ -0,0 +1,7 @@ +jQuery(function ($) { + // Make select beautiful + $('select.select2').select2({ + allowClear: true, + dropdownAutoWidth: true + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/states.js b/backend/app/assets/javascripts/spree/backend/states.js new file mode 100755 index 00000000000..ae8196cf4f1 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/states.js @@ -0,0 +1,13 @@ +$(document).ready(function () { + 'use strict' + + if ($('#new_state_link').length) { + $('#country').on('change', function () { + var newStateLinkHref = $('#new_state_link').prop('href') + var selectedCountryId = $('#country option:selected').prop('value') + var newLink = newStateLinkHref.replace(/countries\/(\d+)/, + 'countries/' + selectedCountryId) + $('#new_state_link').attr('href', newLink) + }) + }; +}) diff --git a/backend/app/assets/javascripts/spree/backend/stock_location.js b/backend/app/assets/javascripts/spree/backend/stock_location.js new file mode 100644 index 00000000000..0c62f440347 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/stock_location.js @@ -0,0 +1,4 @@ +/* global update_state */ +$(document).ready(function () { + $('[data-hook=stock_location_country] span#country .select2').on('change', function () { update_state('') }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/stock_management.js b/backend/app/assets/javascripts/spree/backend/stock_management.js new file mode 100644 index 00000000000..50ca40a1946 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/stock_management.js @@ -0,0 +1,13 @@ +$(function () { + $('.stock_item_backorderable').on('click', function () { + $(this).parent('form').submit() + }) + $('.toggle_stock_item_backorderable').on('submit', function () { + $.ajax({ + type: this.method, + url: this.action, + data: $(this).serialize() + }) + return false + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/stock_movement.js b/backend/app/assets/javascripts/spree/backend/stock_movement.js new file mode 100644 index 00000000000..067bb5498ac --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/stock_movement.js @@ -0,0 +1,35 @@ +/* global variantTemplate */ +$(function () { + var el = $('#stock_movement_stock_item_id') + el.select2({ + placeholder: 'Find a stock item', // translate + ajax: { + url: Spree.url(Spree.routes.stock_items_api(el.data('stock-location-id'))), + data: function (term, page) { + return { + q: { + variant_product_name_cont: term + }, + per_page: 50, + page: page, + token: Spree.api_key + } + }, + results: function (data, page) { + var more = (page * 50) < data.count + return { + results: data.stock_items, + more: more + } + } + }, + formatResult: function (stockItem) { + return variantTemplate({ + variant: stockItem.variant + }) + }, + formatSelection: function (stockItem) { + return Select2.util.escapeMarkup(stockItem.variant.name + '(' + stockItem.variant.options_text + ')') + } + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/stock_transfer.js b/backend/app/assets/javascripts/spree/backend/stock_transfer.js new file mode 100644 index 00000000000..9ee836173cb --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/stock_transfer.js @@ -0,0 +1,260 @@ +$(function () { + function TransferVariant (variant1) { + this.variant = variant1 + this.id = this.variant.id + this.name = this.variant.name + ' - ' + this.variant.sku + this.quantity = 0 + } + TransferVariant.prototype.add = function (quantity) { + this.quantity += quantity + return this.quantity + } + + function TransferLocations () { + this.source = $('#transfer_source_location_id') + this.destination = $('#transfer_destination_location_id') + this.source.change(this.populate_destination.bind(this)) + $('#transfer_receive_stock').change(this.receive_stock_change.bind(this)) + $.getJSON(Spree.url(Spree.routes.stock_locations_api) + '?token=' + Spree.api_key + '&per_page=1000', function (data) { + this.locations = (function () { + var ref = data.stock_locations + var results = [] + var i, len + for (i = 0, len = ref.length; i < len; i++) { + results.push(ref[i]) + } + return results + })() + if (this.locations.length < 2) { + this.force_receive_stock() + } + this.populate_source() + this.populate_destination() + }.bind(this)) + } + + TransferLocations.prototype.force_receive_stock = function () { + $('#receive_stock_field').hide() + $('#transfer_receive_stock').prop('checked', true) + this.toggle_source_location(true) + } + + TransferLocations.prototype.is_source_location_hidden = function () { + return $('#transfer_source_location_id_field').css('visibility') === 'hidden' + } + + TransferLocations.prototype.toggle_source_location = function (hide) { + if (hide == null) { + hide = false + } + this.source.trigger('change') + var transferSourceLocationIdField = $('#transfer_source_location_id_field') + if (this.is_source_location_hidden() && !hide) { + transferSourceLocationIdField.css('visibility', 'visible') + transferSourceLocationIdField.show() + } else { + transferSourceLocationIdField.css('visibility', 'hidden') + transferSourceLocationIdField.hide() + } + } + + TransferLocations.prototype.receive_stock_change = function (event) { + this.toggle_source_location(event.target.checked) + this.populate_destination(!event.target.checked) + } + + TransferLocations.prototype.populate_source = function () { + this.populate_select(this.source) + this.source.trigger('change') + } + + TransferLocations.prototype.populate_destination = function () { + if (this.is_source_location_hidden()) { + return this.populate_select(this.destination) + } else { + return this.populate_select(this.destination, parseInt(this.source.val())) + } + } + + TransferLocations.prototype.populate_select = function (select, except) { + var i, len, location, ref + if (except == null) { + except = 0 + } + select.children('option').remove() + ref = this.locations + for (i = 0, len = ref.length; i < len; i++) { + location = ref[i] + if (location.id !== except) { + select.append($('').text(location.name).attr('value', location.id)) + } + } + return select.select2() + } + + function TransferVariants () { + $('#transfer_source_location_id').change(this.refresh_variants.bind(this)) + } + + TransferVariants.prototype.receiving_stock = function () { + return $('#transfer_receive_stock:checked').length > 0 + } + + TransferVariants.prototype.refresh_variants = function () { + if (this.receiving_stock()) { + return this._search_transfer_variants() + } else { + return this._search_transfer_stock_items() + } + } + + TransferVariants.prototype._search_transfer_variants = function () { + return this.build_select(Spree.url(Spree.routes.variants_api), 'product_name_or_sku_cont') + } + + TransferVariants.prototype._search_transfer_stock_items = function () { + var stockLocationId = $('#transfer_source_location_id').val() + return this.build_select(Spree.url(Spree.routes.stock_locations_api + ('/' + stockLocationId + '/stock_items')), 'variant_product_name_or_variant_sku_cont') + } + + TransferVariants.prototype.format_variant_result = function (result) { + // eslint-disable-next-line no-extra-boolean-cast + if (!!result.options_text) { + return result.name + ' - ' + result.sku + ' (' + result.options_text + ')' + } else { + return result.name + ' - ' + result.sku + } + } + + TransferVariants.prototype.build_select = function (url, query) { + return $('#transfer_variant').select2({ + minimumInputLength: 3, + ajax: { + url: url, + datatype: 'json', + data: function (term) { + var q = {} + q[query] = term + return { + q: q, + token: Spree.api_key + } + }, + results: function (data) { + var result = data['variants'] || data['stock_items'] + if (data['stock_items'] != null) { + result = _(result).map(function (variant) { + return variant.variant + }) + } + window.variants = result + return { + results: result + } + } + }, + formatResult: this.format_variant_result, + formatSelection: function (variant) { + // eslint-disable-next-line no-extra-boolean-cast + if (!!variant.options_text) { + return variant.name + (' (' + variant.options_text + ')') + (' - ' + variant.sku) + } else { + return variant.name + (' - ' + variant.sku) + } + } + }) + } + + function TransferAddVariants () { + this.variants = [] + this.template = Handlebars.compile($('#transfer_variant_template').html()) + $('#transfer_source_location_id').change(this.clear_variants.bind(this)) + $('button.transfer_add_variant').click(function (event) { + event.preventDefault() + if ($('#transfer_variant').select2('data') != null) { + this.add_variant() + } else { + alert('Please select a variant first') + } + }.bind(this)) + $('#transfer-variants-table').on('click', '.transfer_remove_variant', function (event) { + event.preventDefault() + this.remove_variant($(event.target)) + }.bind(this)) + $('button.transfer_transfer').click(function () { + if (!(this.variants.length > 0)) { + alert('no variants to transfer') + return false + } + }.bind(this)) + } + + TransferAddVariants.prototype.add_variant = function () { + var variant = $('#transfer_variant').select2('data') + var quantity = parseInt($('#transfer_variant_quantity').val()) + variant = this.find_or_add(variant) + variant.add(quantity) + return this.render() + } + + TransferAddVariants.prototype.find_or_add = function (variant) { + var existing = _.find(this.variants, function (v) { + return v.id === variant.id + }) + if (existing) { + return existing + } else { + variant = new TransferVariant($.extend({}, variant)) + this.variants.push(variant) + return variant + } + } + + TransferAddVariants.prototype.remove_variant = function (target) { + var v + var variantId = parseInt(target.data('variantId')) + this.variants = (function () { + var ref = this.variants + var results = [] + var i, len + for (i = 0, len = ref.length; i < len; i++) { + v = ref[i] + if (v.id !== variantId) { + results.push(v) + } + } + return results + }.call(this)) + return this.render() + } + + TransferAddVariants.prototype.clear_variants = function () { + this.variants = [] + return this.render() + } + + TransferAddVariants.prototype.contains = function (id) { + return _.contains(_.pluck(this.variants, 'id'), id) + } + + TransferAddVariants.prototype.render = function () { + if (this.variants.length === 0) { + $('#transfer-variants-table').hide() + return $('.no-objects-found').show() + } else { + $('#transfer-variants-table').show() + $('.no-objects-found').hide() + return $('#transfer_variants_tbody').html(this.template({ + variants: this.variants + })) + } + } + + if ($('#transfer_source_location_id').length > 0) { + /* eslint-disable no-new */ + new TransferLocations() + new TransferVariants() + new TransferAddVariants() + /* eslint-enable no-new */ + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/tag_picker.js b/backend/app/assets/javascripts/spree/backend/tag_picker.js new file mode 100644 index 00000000000..088a1346a7c --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/tag_picker.js @@ -0,0 +1,52 @@ +$.fn.tagAutocomplete = function () { + 'use strict' + + function formatTag (tag) { + return Select2.util.escapeMarkup(tag.name) + } + + this.select2({ + placeholder: Spree.translations.tags_placeholder, + minimumInputLength: 1, + tokenSeparators: [','], + multiple: true, + tags: true, + initSelection: function (element, callback) { + var data = $(element.val().split(',')).map(function () { + return { name: this, id: this } + }) + callback(data) + }, + ajax: { + url: Spree.routes.tags_api, + datatype: 'json', + cache: true, + data: function (term) { + return { + q: term, + token: Spree.api_key + } + }, + results: function (data) { + return { + results: data.tags.map(function (tag) { + return { name: tag.name, id: tag.name } + }) + } + } + }, + createSearchChoice: function (term, data) { + if ($(data).filter(function () { + return this.name.localeCompare(term) === 0 + }).length === 0) { + return { id: term, name: term } + } + }, + formatResult: formatTag, + formatSelection: formatTag + }) +} + +$(document).ready(function () { + $('.tag_picker').tagAutocomplete() +}) diff --git a/backend/app/assets/javascripts/spree/backend/taxon_autocomplete.js b/backend/app/assets/javascripts/spree/backend/taxon_autocomplete.js new file mode 100644 index 00000000000..d5966a40d45 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/taxon_autocomplete.js @@ -0,0 +1,52 @@ +'use strict' +// eslint-disable-next-line camelcase +function set_taxon_select (selector) { + function formatTaxon (taxon) { + return Select2.util.escapeMarkup(taxon.pretty_name) + } + + if ($(selector).length > 0) { + $(selector).select2({ + placeholder: Spree.translations.taxon_placeholder, + multiple: true, + initSelection: function (element, callback) { + var url = Spree.url(Spree.routes.taxons_api, { + ids: element.val(), + without_children: true, + token: Spree.api_key + }) + return $.getJSON(url, null, function (data) { + return callback(data['taxons']) + }) + }, + ajax: { + url: Spree.routes.taxons_api, + datatype: 'json', + data: function (term, page) { + return { + per_page: 50, + page: page, + without_children: true, + q: { + name_cont: term + }, + token: Spree.api_key + } + }, + results: function (data, page) { + var more = page < data.pages + return { + results: data['taxons'], + more: more + } + } + }, + formatResult: formatTaxon, + formatSelection: formatTaxon + }) + } +} + +$(document).ready(function () { + set_taxon_select('#product_taxon_ids') +}) diff --git a/backend/app/assets/javascripts/spree/backend/taxon_permalink_preview.js b/backend/app/assets/javascripts/spree/backend/taxon_permalink_preview.js new file mode 100644 index 00000000000..e96181c49bb --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/taxon_permalink_preview.js @@ -0,0 +1,11 @@ +$(document).ready(function () { + if ($('#permalink_part_display').length) { + var field = $('#permalink_part') + var target = $('#permalink_part_display') + var permalinkPartDefault = target.text().trim() + target.text(permalinkPartDefault + field.val()) + field.on('keyup blur', function () { + target.text(permalinkPartDefault + $(this).val()) + }) + }; +}) diff --git a/backend/app/assets/javascripts/spree/backend/taxon_tree_menu.js b/backend/app/assets/javascripts/spree/backend/taxon_tree_menu.js new file mode 100644 index 00000000000..ecba723e1c0 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/taxon_tree_menu.js @@ -0,0 +1,35 @@ +var root = typeof exports !== 'undefined' && exports !== null ? exports : this + +root.taxon_tree_menu = function (obj, context) { + var adminBaseUrl = Spree.url(Spree.routes.admin_taxonomy_taxons_path) + var editUrl = adminBaseUrl.clone() + editUrl.setPath(editUrl.path() + '/' + obj.attr('id') + '/edit') + return { + create: { + label: ' ' + Spree.translations.add, + action: function (obj) { + return context.create(obj) + } + }, + rename: { + label: ' ' + Spree.translations.rename, + action: function (obj) { + return context.rename(obj) + } + }, + remove: { + label: ' ' + Spree.translations.remove, + action: function (obj) { + return context.remove(obj) + } + }, + edit: { + separator_before: true, + label: ' ' + Spree.translations.edit, + action: function () { + window.location = editUrl.toString() + return window.location + } + } + } +} diff --git a/backend/app/assets/javascripts/spree/backend/taxonomy.js b/backend/app/assets/javascripts/spree/backend/taxonomy.js new file mode 100644 index 00000000000..d41389251c3 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/taxonomy.js @@ -0,0 +1,165 @@ +function handle_ajax_error(last_rollback) { + $.jstree.rollback(last_rollback); + show_flash('error', '' + Spree.translations.server_error + '
    ' + Spree.translations.taxonomy_tree_error); +} + +function handle_move(e, data) { + var last_rollback = data.rlbk; + var position = data.rslt.cp; + var node = data.rslt.o; + var new_parent = data.rslt.np; + var url = Spree.url(base_url).clone(); + url.setPath(url.path() + '/' + node.prop('id')); + $.ajax({ + type: 'POST', + dataType: 'json', + url: url.toString(), + data: { + _method: 'put', + 'taxon[parent_id]': new_parent.prop('id'), + 'taxon[child_index]': position, + token: Spree.api_key + }, + }).fail(function () { + handle_ajax_error(last_rollback); + }); + return true; +} + +function handle_create(e, data) { + var last_rollback = data.rlbk; + var node = data.rslt.obj; + var name = data.rslt.name; + var position = data.rslt.position; + var new_parent = data.rslt.parent; + return $.ajax({ + type: 'POST', + dataType: 'json', + url: base_url.toString(), + data: { + 'taxon[name]': name, + 'taxon[parent_id]': new_parent.prop('id'), + 'taxon[child_index]': position, + token: Spree.api_key + } + }).done(function (data) { + node.prop('id', data.id); + }).fail(function () { + return handle_ajax_error(last_rollback); + }); +} + +function handle_rename(e, data) { + var last_rollback = data.rlbk; + var node = data.rslt.obj; + var name = data.rslt.new_name; + var url = Spree.url(base_url).clone(); + url.setPath(url.path() + '/' + node.prop('id')); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url.toString(), + data: { + _method: 'put', + 'taxon[name]': name, + token: Spree.api_key + } + }).fail(function () { + handle_ajax_error(last_rollback); + }); +} + +function handle_delete(e, data) { + var last_rollback = data.rlbk; + var node = data.rslt.obj; + var delete_url = base_url.clone(); + delete_url.setPath(delete_url.path() + '/' + node.prop('id')); + if (confirm(Spree.translations.are_you_sure_delete)) { + $.ajax({ + type: 'POST', + dataType: 'json', + url: delete_url.toString(), + data: { + _method: 'delete', + token: Spree.api_key + } + }).fail(function () { + handle_ajax_error(last_rollback); + }); + } else { + $.jstree.rollback(last_rollback); + last_rollback = null; + } +} + +var root = typeof exports !== 'undefined' && exports !== null ? exports : this; + +root.setup_taxonomy_tree = function (taxonomy_id) { + var $taxonomy_tree = $('#taxonomy_tree'); + if (taxonomy_id !== void 0) { + // this is defined within admin/taxonomies/edit + root.base_url = Spree.url(Spree.routes.taxonomy_taxons_path); + $.ajax({ + url: Spree.url(base_url.path().replace('/taxons', '/jstree')).toString(), + data: { + token: Spree.api_key + } + }).done(function (taxonomy) { + var last_rollback = null; + var conf = { + json_data: { + data: taxonomy, + ajax: { + url: function (e) { + return Spree.url(base_url.path() + '/' + e.prop('id') + '/jstree' + '?token=' + Spree.api_key).toString(); + } + } + }, + themes: { + theme: 'spree', + url: Spree.url(Spree.routes.jstree_theme_path) + }, + strings: { + new_node: Spree.translations.new_taxon, + loading: Spree.translations.loading + '...' + }, + crrm: { + move: { + check_move: function (m) { + var new_parent, node, position; + position = m.cp; + node = m.o; + new_parent = m.np; + if (!new_parent || node.prop('rel') === 'root') { + return false; + } + // can't drop before root + if (new_parent.prop('id') === 'taxonomy_tree' && position === 0) { + return false; + } + return true; + } + } + }, + contextmenu: { + items: function (obj) { + return taxon_tree_menu(obj, this); + } + }, + plugins: ['themes', 'json_data', 'dnd', 'crrm', 'contextmenu'] + }; + return $taxonomy_tree.jstree(conf).bind('move_node.jstree', handle_move).bind('remove.jstree', handle_delete).bind('create.jstree', handle_create).bind('rename.jstree', handle_rename).bind('loaded.jstree', function () { + return $(this).jstree('core').toggle_node($('.jstree-icon').first()); + }); + }); + $taxonomy_tree.on('dblclick', 'a', function () { + $taxonomy_tree.jstree('rename', this); + }); + // surpress form submit on enter/return + $(document).keypress(function (event) { + if (event.keyCode === 13) { + event.preventDefault(); + } + }); + } +}; diff --git a/backend/app/assets/javascripts/spree/backend/taxons.js b/backend/app/assets/javascripts/spree/backend/taxons.js new file mode 100644 index 00000000000..3340107cb6a --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/taxons.js @@ -0,0 +1,122 @@ +/* global productTemplate */ +$(function () { + window.productTemplate = Handlebars.compile($('#product_template').text()) + var taxonProducts = $('#taxon_products') + var taxonId = $('#taxon_id') + + taxonProducts.sortable({ + handle: '.js-sort-handle' + }) + + taxonProducts.on('sortstop', function (event, ui) { + return $.ajax({ + url: Spree.routes.classifications_api, + method: 'PUT', + dataType: 'json', + data: { + token: Spree.api_key, + product_id: ui.item.data('product-id'), + taxon_id: $('#taxon_id').val(), + position: ui.item.index() + } + }) + }) + + if (taxonId.length > 0) { + taxonId.select2({ + dropdownCssClass: 'taxon_select_box', + placeholder: Spree.translations.find_a_taxon, + ajax: { + url: Spree.routes.taxons_api, + datatype: 'json', + data: function (term, page) { + return { + per_page: 50, + page: page, + without_children: true, + token: Spree.api_key, + q: { + name_cont: term + } + } + }, + results: function (data, page) { + var more = page < data.pages + return { + results: data['taxons'], + more: more + } + } + }, + formatResult: formatTaxon, + formatSelection: formatTaxon + }) + } + + taxonId.on('change', function (e) { + var el = $('#taxon_products') + $.ajax({ + url: Spree.routes.taxon_products_api, + data: { + id: e.val, + token: Spree.api_key + } + }).done(function (data) { + var i, j, len, len1, product, ref, ref1, results, variant + el.empty() + if (data.products.length === 0) { + return $('#taxon_products').html('
    ' + Spree.translations.no_results + '
    ') + } else { + ref = data.products + results = [] + for (i = 0, len = ref.length; i < len; i++) { + product = ref[i] + if (product.master.images[0] !== void 0 && product.master.images[0].small_url !== void 0) { + product.image = product.master.images[0].small_url + } else { + ref1 = product.variants + for (j = 0, len1 = ref1.length; j < len1; j++) { + variant = ref1[j] + if (variant.images[0] !== void 0 && variant.images[0].small_url !== void 0) { + product.image = variant.images[0].small_url + break + } + } + } + results.push(el.append(productTemplate({ + product: product + }))) + } + return results + } + }) + }) + taxonProducts.on('click', '.js-delete-product', function (e) { + var currentTaxonId = $('#taxon_id').val() + var product = $(this).parents('.product') + var productId = product.data('product-id') + var productTaxons = String(product.data('taxons')).split(',').map(Number) + var productIndex = productTaxons.indexOf(parseFloat(currentTaxonId)) + productTaxons.splice(productIndex, 1) + var taxonIds = productTaxons.length > 0 ? productTaxons : [''] + $.ajax({ + url: Spree.routes.products_api + '/' + productId, + data: { + product: { + taxon_ids: taxonIds + }, + token: Spree.api_key + }, + type: 'PUT' + }).done(function () { + product.fadeOut(400, function (e) { + product.remove() + }) + }) + }) + $('.variant_autocomplete').variantAutocomplete() + + function formatTaxon (taxon) { + return Select2.util.escapeMarkup(taxon.pretty_name) + } +}) diff --git a/backend/app/assets/javascripts/spree/backend/user_picker.js b/backend/app/assets/javascripts/spree/backend/user_picker.js new file mode 100644 index 00000000000..daf11b00611 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/user_picker.js @@ -0,0 +1,43 @@ +$.fn.userAutocomplete = function () { + 'use strict' + + function formatUser (user) { + return Select2.util.escapeMarkup(user.email) + } + + this.select2({ + minimumInputLength: 1, + multiple: true, + initSelection: function (element, callback) { + $.get(Spree.routes.users_api, { + ids: element.val(), + token: Spree.api_key + }, function (data) { + callback(data.users) + }) + }, + ajax: { + url: Spree.routes.users_api, + datatype: 'json', + data: function (term) { + return { + q: { + email_cont: term + }, + token: Spree.api_key + } + }, + results: function (data) { + return { + results: data.users + } + } + }, + formatResult: formatUser, + formatSelection: formatUser + }) +} + +$(document).ready(function () { + $('.user_picker').userAutocomplete() +}) diff --git a/backend/app/assets/javascripts/spree/backend/users/edit.js b/backend/app/assets/javascripts/spree/backend/users/edit.js new file mode 100644 index 00000000000..32fe6e96cec --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/users/edit.js @@ -0,0 +1,17 @@ +$(document).ready(function () { + var useBilling = $('#user_use_billing') + + if (useBilling.is(':checked')) { + $('#shipping').hide() + } + + useBilling.change(function () { + if (this.checked) { + $('#shipping').hide() + return $('#shipping input, #shipping select').prop('disabled', true) + } else { + $('#shipping').show() + $('#shipping input, #shipping select').prop('disabled', false) + } + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/variant_autocomplete.js b/backend/app/assets/javascripts/spree/backend/variant_autocomplete.js new file mode 100644 index 00000000000..b93a1d711f4 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/variant_autocomplete.js @@ -0,0 +1,61 @@ +/* global variantTemplate */ +// variant autocompletion +$(function () { + var variantAutocompleteTemplate = $('#variant_autocomplete_template') + if (variantAutocompleteTemplate.length > 0) { + window.variantTemplate = Handlebars.compile(variantAutocompleteTemplate.text()) + window.variantStockTemplate = Handlebars.compile($('#variant_autocomplete_stock_template').text()) + window.variantLineItemTemplate = Handlebars.compile($('#variant_line_items_autocomplete_stock_template').text()) + } +}) + +function formatVariantResult (variant) { + if (variant['images'][0] !== undefined && variant['images'][0].mini_url !== undefined) { + variant.image = variant.images[0].mini_url + } + return variantTemplate({ + variant: variant + }) +} + +$.fn.variantAutocomplete = function () { + return this.select2({ + placeholder: Spree.translations.variant_placeholder, + minimumInputLength: 3, + initSelection: function (element, callback) { + return $.get(Spree.routes.variants_api + '/' + element.val(), { + token: Spree.api_key + }).done(function (data) { + return callback(data) + }) + }, + ajax: { + url: Spree.url(Spree.routes.variants_api), + quietMillis: 200, + datatype: 'json', + data: function (term) { + return { + q: { + product_name_or_sku_cont: term + }, + token: Spree.api_key + } + }, + results: function (data) { + window.variants = data['variants'] + return { + results: data['variants'] + } + } + }, + formatResult: formatVariantResult, + formatSelection: function (variant) { + // eslint-disable-next-line no-extra-boolean-cast + if (!!variant.options_text) { + return Select2.util.escapeMarkup(variant.name + '(' + variant.options_text + ')') + } else { + return Select2.util.escapeMarkup(variant.name) + } + } + }) +} diff --git a/backend/app/assets/javascripts/spree/backend/variant_management.js b/backend/app/assets/javascripts/spree/backend/variant_management.js new file mode 100644 index 00000000000..1c4475d2912 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/variant_management.js @@ -0,0 +1,14 @@ +$(function () { + $('.track_inventory_checkbox').on('click', function () { + $(this).siblings('.variant_track_inventory').val($(this).is(':checked')) + $(this).parents('form').submit() + }) + $('.toggle_variant_track_inventory').on('submit', function () { + $.ajax({ + type: this.method, + url: this.action, + data: $(this).serialize() + }) + return false + }) +}) diff --git a/backend/app/assets/javascripts/spree/backend/zone.js b/backend/app/assets/javascripts/spree/backend/zone.js new file mode 100644 index 00000000000..c894c3994b9 --- /dev/null +++ b/backend/app/assets/javascripts/spree/backend/zone.js @@ -0,0 +1,44 @@ +$(function () { + var countryBased = $('#country_based') + var stateBased = $('#state_based') + countryBased.click(show_country) + stateBased.click(show_state) + if (countryBased.is(':checked')) { + show_country() + } else if (stateBased.is(':checked')) { + show_state() + } else { + show_state() + stateBased.click() + } +}) +// eslint-disable-next-line camelcase +function show_country () { + $('#state_members :input').each(function () { + $(this).prop('disabled', true) + }) + $('#state_members').hide() + $('#zone_members :input').each(function () { + $(this).prop('disabled', true) + }) + $('#zone_members').hide() + $('#country_members :input').each(function () { + $(this).prop('disabled', false) + }) + $('#country_members').show() +} +// eslint-disable-next-line camelcase +function show_state () { + $('#country_members :input').each(function () { + $(this).prop('disabled', true) + }) + $('#country_members').hide() + $('#zone_members :input').each(function () { + $(this).prop('disabled', true) + }) + $('#zone_members').hide() + $('#state_members :input').each(function () { + $(this).prop('disabled', false) + }) + $('#state_members').show() +} diff --git a/backend/app/assets/javascripts/spree/frontend/backend.js b/backend/app/assets/javascripts/spree/frontend/backend.js new file mode 100644 index 00000000000..e44d478def9 --- /dev/null +++ b/backend/app/assets/javascripts/spree/frontend/backend.js @@ -0,0 +1 @@ +// Placeholder for backend dummy application diff --git a/backend/app/assets/stylesheets/spree/backend.css b/backend/app/assets/stylesheets/spree/backend.css new file mode 100644 index 00000000000..be5dc56848b --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend.css @@ -0,0 +1,11 @@ +/* + * This is a manifest file that'll automatically include all the stylesheets available in this directory + * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at + * the top of the compiled file, but it's generally better to create a new file per style scope. + + *= require jquery-ui/datepicker + *= require jquery-ui/autocomplete + *= require select2 + *= require animate + *= require spree/backend/spree_admin +*/ diff --git a/backend/app/assets/stylesheets/spree/backend/components/_buttons.scss b/backend/app/assets/stylesheets/spree/backend/components/_buttons.scss new file mode 100644 index 00000000000..1001c6800e5 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_buttons.scss @@ -0,0 +1,41 @@ +.btn, +.nav-pills > li > a { + letter-spacing: 1px; + vertical-align: middle; + transition: color 0.1s linear 0s,background-color 0.1s linear 0s,opacity 0.2s linear 0s !important; + + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + -webkit-font-feature-settings: "kern" 1; + -moz-font-feature-settings: "kern" 1; +} + +.btn { + text-align: center; + + &:focus { + outline: 0 !important; + } + + &:hover, + &:focus, + &:active, + &.active, + &.disabled, + &[disabled] { + box-shadow: none; + } + + .glyphicon { + padding-right: 3px; + } + + .ship { color: $btn-success-color; } +} + +.btn-sm .glyphicon { padding: 0; } + +.btn.action-void { @extend .btn-danger; } +.btn.action-capture { @extend .btn-success; } +.btn.payment-action-edit { @extend .btn-primary; } diff --git a/backend/app/assets/stylesheets/spree/backend/components/_filters.scss b/backend/app/assets/stylesheets/spree/backend/components/_filters.scss new file mode 100644 index 00000000000..ade21d2f98b --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_filters.scss @@ -0,0 +1,26 @@ +#table-filter { + display: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: 20px; +} + +.index-filter-button { + .glyphicon { + color: $gray-light; + } + + button { + text-transform: uppercase; + letter-spacing: 2px; + font-weight: bold; + font-size: $font-size-base - 3; + padding: ($padding-base-vertical + 3) $padding-base-horizontal ($padding-base-vertical + 2); + border-right: none; + + &:focus { + background-color: $gray-lighter; + border-color: $input-border; + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_icons.scss b/backend/app/assets/stylesheets/spree/backend/components/_icons.scss new file mode 100644 index 00000000000..bb0004b6272 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_icons.scss @@ -0,0 +1,257 @@ +// TODO: Switch everything to use glyphicons +.icon, .glyphicon { + vertical-align: text-top; +} + +// Maps Glyphicons to Spree .icon prefix. +// You can apply same pattern to extend with more icons. +// +// example: +// glyphicon glyphicon-pencil => icon icon-edit +// + +// All Glyphicons Halflings in Bootstrap v3.3.1.0 +$default_icons: ( + adjust + align-center + align-justify + align-left + align-right + arrow-down + arrow-left + arrow-right + arrow-up + asterisk + backward + ban-circle + barcode + bell + bold + book + bookmark + briefcase + bullhorn + calendar + camera + certificate + check + chevron-down + chevron-left + chevron-right + chevron-up + circle-arrow-down + circle-arrow-left + circle-arrow-right + circle-arrow-up + cloud + cloud-download + cloud-upload + cog + collapse-down + collapse-up + comment + compressed + copyright-mark + credit-card + cutlery + dashboard + download + download-alt + earphone + eject + envelope + euro + exclamation-sign + expand + export + eye-close + eye-open + facetime-video + fast-backward + fast-forward + file + film + filter + fire + flag + flash + floppy-disk + floppy-open + floppy-remove + floppy-save + floppy-saved + folder-close + folder-open + font + forward + fullscreen + gbp + gift + glass + globe + hand-down + hand-left + hand-right + hand-up + hd-video + hdd + header + headphones + heart + heart-empty + home + import + inbox + indent-left + indent-right + info-sign + italic + leaf + link + list + list-alt + lock + log-in + log-out + magnet + map-marker + minus + minus-sign + move + music + new-window + off + ok + ok-circle + ok-sign + open + paperclip + pause + pencil + phone + phone-alt + picture + plane + play + play-circle + plus + plus-sign + print + pushpin + qrcode + question-sign + random + record + refresh + registration-mark + remove + remove-circle + remove-sign + repeat + resize-full + resize-horizontal + resize-small + resize-vertical + retweet + road + saved + screenshot + sd-video + search + send + share + share-alt + shopping-cart + signal + sort + sort-by-alphabet + sort-by-alphabet-alt + sort-by-attributes + sort-by-attributes-alt + sort-by-order + sort-by-order-alt + sound-5-1 + sound-6-1 + sound-7-1 + sound-dolby + sound-stereo + star + star-empty + stats + step-backward + step-forward + stop + subtitles + tag + tags + tasks + text-height + text-width + th + th-large + th-list + thumbs-down + thumbs-up + time + tint + tower + transfer + trash + tree-conifer + tree-deciduous + unchecked + upload + usd + user + volume-down + volume-off + volume-up + warning-sign + wrench + zoom-in + zoom-out +); + +// We prefer alias/remap these so its easy to understand which icon to use. +// Note: If desired alias exist as default, then remove it from default Array, +// or write a smarter @each scope below that can actually override. +// ==========> default | alias +$alias_icons: (plus add) + (pencil edit) + (ok capture) + (remove void) + (bullhorn promotion) + (share return) + (send shipment) + (share clone) + (refresh update) + (remove cancel) + (resize-full split) + (ok save) + (eye-open show) + (remove delete) + (minus refund) + (th-large products) + (th-large variants) + (picture images) + (tag properties) + (inbox stock) + (usd money) + (globe translate) + (ok approve); + +.icon { + @extend .glyphicon; + + // Map defaults. + @each $icon in $default_icons { + &.icon-#{$icon} { @extend .glyphicon-#{$icon}; } + } + + // Map and override with aliases. + @each $map in $alias_icons { + $icon: nth($map, 1); + $alias: nth($map, 2); + &.icon-#{$alias} { @extend .glyphicon-#{$icon}; } + } +} \ No newline at end of file diff --git a/backend/app/assets/stylesheets/spree/backend/components/_labels.scss b/backend/app/assets/stylesheets/spree/backend/components/_labels.scss new file mode 100644 index 00000000000..3e2741b83e5 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_labels.scss @@ -0,0 +1,55 @@ +// Green +.label-completed, +.label-complete, +.label-resumed, +.label-considered_safe, +.label-paid, +.label-shipped, +.label-ready, +.label-active, +.label-success, +.label-sold, +.label-open, +.label-authorized +{ + background-color: $brand-success; + + a { + color: white !important; + } +} + +// Red +.label-failed, +.label-considered_risky, +.label-error, +.label-invalid, +.label-closed { + background-color: $brand-danger; +} + +// Orange +.label-processing, +.label-pending, +.label-awaiting_return, +.label-returned, +.label-credit_owed, +.label-balance_due, +.label-backorder, +.label-checkout, +.label-cart, +.label-address, +.label-delivery, +.label-payment, +.label-confirm, +.label-canceled, +.label-void, +.label-inactive, +.label-notice, +.label-partial { + background-color: $brand-warning; + + a { + color: white !important; + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_main.scss b/backend/app/assets/stylesheets/spree/backend/components/_main.scss new file mode 100644 index 00000000000..4e2fd452df1 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_main.scss @@ -0,0 +1,15 @@ +#main-part { + padding: 15px 35px 0; + &.sidebar-collapsed { + padding-left: 85px; + } +} + +.logo.navbar-brand { + padding: 5px 15px; +} + +#logo { + width: auto; + height: 40px; +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_navigation.scss b/backend/app/assets/stylesheets/spree/backend/components/_navigation.scss new file mode 100644 index 00000000000..d0edbe80b0d --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_navigation.scss @@ -0,0 +1,14 @@ +#sidebar-toggle { + display: block; + border: none; + padding: 0; + margin: 0; + position: absolute; + right: 0; + margin-top: 17px; + cursor: pointer; +} + +.navbar .row { + padding-bottom: 0; +} \ No newline at end of file diff --git a/backend/app/assets/stylesheets/spree/backend/components/_page_header.scss b/backend/app/assets/stylesheets/spree/backend/components/_page_header.scss new file mode 100644 index 00000000000..6e8e3c91713 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_page_header.scss @@ -0,0 +1,32 @@ +.content-header.page-header { + margin-top: 15px; + + h1 { + font-size: $font-size-h1 / 1.5; + margin: 0; + line-height: 38px; + } + + &.with-page-tabs { + border-bottom: 0; + margin-bottom: 6px; + padding-bottom: 0; + + h1 { + margin-bottom: 8px; + } + + .nav-tabs { + padding-left: 15px; + padding-top: 15px; + } + } + + .page-actions { + text-align: right; + + form { + display: inline-block + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_panels.scss b/backend/app/assets/stylesheets/spree/backend/components/_panels.scss new file mode 100644 index 00000000000..61f7a86c2a8 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_panels.scss @@ -0,0 +1,3 @@ +.panel { + box-shadow: none; +} \ No newline at end of file diff --git a/backend/app/assets/stylesheets/spree/backend/components/_progress.scss b/backend/app/assets/stylesheets/spree/backend/components/_progress.scss new file mode 100644 index 00000000000..0310e3ea74a --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_progress.scss @@ -0,0 +1,22 @@ +// The progress loader which is positioned at the bottom of the page +#progress { + display: none; + width: 100%; + position: fixed; + left: 0; + bottom: 0; + z-index: 1001; + .alert { + box-shadow: none; + margin: 0; + border-radius: 0; + .progress-message { + color: #31708f; + font-weight: bold; + text-align: center; + } + .spinner { + float: right; + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_sidebar.scss b/backend/app/assets/stylesheets/spree/backend/components/_sidebar.scss new file mode 100644 index 00000000000..627b5a1d7dd --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_sidebar.scss @@ -0,0 +1,129 @@ +#main-sidebar { + position: fixed; + top: $navbar-height + 2px; + bottom: 0; + left: 0; + padding: 0; + z-index: 1000; + display: block; + overflow-x: hidden; + overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ + background-color: lighten($gray-lighter, 5); + border-right: 1px solid darken($gray-lighter, 5); + + a { + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + -webkit-font-feature-settings: "kern" 1; + -moz-font-feature-settings: "kern" 1; + } + + ul.nav-sidebar { + > li { + border-bottom: 1px solid $gray-lighter; + + > a { + text-transform: uppercase; + letter-spacing: 1px; + font-size: 12px; + font-weight: 500; + padding: 15px; + color: $gray; + + .glyphicon { + font-size: 18px; + vertical-align: bottom; + padding-right: 5px; + } + + .glyphicon.pull-right { + font-size: 8px; + line-height: 2; + color: $gray-light; + position: absolute; + right: 5px; + top: 50%; + margin-top: -8px; + } + } + } + } + + ul.nav.nav-pills { + background-color: white; + border-top: 1px solid $gray-lighter; + + li { + a { + padding: 5px 15px; + font-size: 12px; + + &:hover { + border-radius: 0; + } + } + + &.selected { + a { + color: $brand-success + } + } + } + } + + .spree-version { + position: absolute; + z-index: -1; + left: 15px; + bottom: 15px; + } +} + +#wrapper.sidebar-minimized #main-sidebar { + width: 50px; + overflow: visible; + + li { + &:hover, &.menu-active { + background-color: $gray-lighter; + } + } + + .icon-link, [data-toggle="collapse"] { + text-align: center; + + .icon { + padding: 0; + } + + .text, .icon-chevron-left, .icon-chevron-down { + display: none; + } + } + + ul.collapse.in { + display: none; + } + + .submenu-active { + display: block !important; + height: auto !important; + position: fixed; + top: $navbar-height + 2px; + bottom: 0; + left: 0; + padding: 0; + z-index: 1000; + display: block; + overflow-x: hidden; + overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ + left: 50px; + min-width: 180px; + visibility: visible; + background-color: lighten($gray-lighter, 5); + border-top: none; + border-right: 1px solid darken($gray-lighter, 5); + padding-top: 5px; + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_spinner.scss b/backend/app/assets/stylesheets/spree/backend/components/_spinner.scss new file mode 100644 index 00000000000..7c7528f94f4 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_spinner.scss @@ -0,0 +1,95 @@ +@-webkit-keyframes spinner { + 0% { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@-moz-keyframes spinner { + 0% { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@-o-keyframes spinner { + 0% { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes spinner { + 0% { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +/* :not(:required) hides this rule from IE9 and below */ +.spinner:not(:required) { + -webkit-animation: spinner 1500ms infinite linear; + -moz-animation: spinner 1500ms infinite linear; + -ms-animation: spinner 1500ms infinite linear; + -o-animation: spinner 1500ms infinite linear; + animation: spinner 1500ms infinite linear; + -webkit-border-radius: 0.5em; + -moz-border-radius: 0.5em; + -ms-border-radius: 0.5em; + -o-border-radius: 0.5em; + border-radius: 0.5em; + -webkit-box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0; + -moz-box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0; + box-shadow: rgba(0, 0, 51, 0.3) 1.5em 0 0 0, rgba(0, 0, 51, 0.3) 1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) 0 1.5em 0 0, rgba(0, 0, 51, 0.3) -1.1em 1.1em 0 0, rgba(0, 0, 51, 0.3) -1.5em 0 0 0, rgba(0, 0, 51, 0.3) -1.1em -1.1em 0 0, rgba(0, 0, 51, 0.3) 0 -1.5em 0 0, rgba(0, 0, 51, 0.3) 1.1em -1.1em 0 0; + display: inline-block; + font-size: 6px; + width: 0.8em; + height: 0.8em; + margin: 1.5em; + overflow: hidden; + text-indent: 100%; +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_tables.scss b/backend/app/assets/stylesheets/spree/backend/components/_tables.scss new file mode 100644 index 00000000000..38085cf1727 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_tables.scss @@ -0,0 +1,56 @@ +table.table { + thead { + th { + text-transform: uppercase; + font-size: $font-size-base - 3px; + padding-top: 14px; + padding-bottom: 14px; + vertical-align: middle; + letter-spacing: 1px; + + a { + .glyphicon { + padding-left: 5px; + } + } + } + } + tbody { + tr { + td { + vertical-align: middle; + .filterable { + font-size: 0.8em; + margin-left: 0.2em; + color: white; // hidden, but space is reserved for the icon + cursor: pointer; + } + } + &:hover td .filterable { + color: #666; + } + } + } +} + +.table-active-filters { + display: none; + margin: -0.5em 0 0.9em 0; + + .label { + display: inline-block; + margin-right: 0.5em; + margin-bottom: 0.5em; + font-size: 85%; + + &:hover { + opacity: 0.8; + } + + span.icon { + margin: 0.3em 0 0 0.6em; + font-size: 80%; + cursor: pointer; + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_taxon_products_view.scss b/backend/app/assets/stylesheets/spree/backend/components/_taxon_products_view.scss new file mode 100644 index 00000000000..96c8b25d598 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_taxon_products_view.scss @@ -0,0 +1,72 @@ +.taxon-products-view { + ul.taxon { + list-style: none; + margin-left: -2%; + padding: 0; + > * { + margin-left: 2%; + } + li.product { + position: relative; + float: left; + width: 18%; + margin-bottom: 2%; + padding: 8px 8px 5px 8px; + border-radius: 4px; + background: #f4f4f4; + .well { + padding-bottom: 12px; + } + .sort-handle { + position: absolute; + top: 14px; + left: 16px; + color: $gray; + cursor: move; + } + .btn-group { + display: none; + position: absolute; + top: 14px; + right: 16px; + .btn { + padding: 2px 7px; + } + .dropdown-menu { + left: auto; + right: 0; + } + a.delete-product { + color: red; + } + } + .image { + height: 191px; + .thumbnail { + max-height: 180px; + margin-bottom: 8px; + display: inline-block; + width: 100%; + } + } + .product-info { + margin-bottom: 0.3em; + font-size: 0.9em; + div.name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: bold; + } + div.price { + color: $gray; + } + } + &:hover { + .btn-group { + display: inline-block; + } + } + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/components/_wells.scss b/backend/app/assets/stylesheets/spree/backend/components/_wells.scss new file mode 100644 index 00000000000..547c2084b96 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/components/_wells.scss @@ -0,0 +1,3 @@ +.well { + box-shadow: none; +} \ No newline at end of file diff --git a/backend/app/assets/stylesheets/spree/backend/global/_variables.scss b/backend/app/assets/stylesheets/spree/backend/global/_variables.scss new file mode 100644 index 00000000000..c20eb6ef084 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/global/_variables.scss @@ -0,0 +1,37 @@ +/*------------------------------------------ + Colors + -----------------------------------------*/ + $brand-primary: #478DC1; + $brand-success: #7DB942; + $brand-info: #48B0F7; + $brand-warning: #F8D053; + $brand-danger: #F55753; + + $input-border: darken(#EDEDED, 5); + +/*------------------------------------------ + Components + -----------------------------------------*/ + $border-radius-base: 2px; + $padding-base-vertical: 8px; + $padding-base-horizontal: 16px; + +/*------------------------------------------ + Fonts + -----------------------------------------*/ + $headings-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + $headings-font-weight: 300; + +/*------------------------------------------ + Buttons + -----------------------------------------*/ + +/*------------------------------------------ + Navbar + -----------------------------------------*/ + $navbar-inverse-bg: darken($brand-primary, 35); + +/*------------------------------------------ + Wells + -----------------------------------------*/ + $well-bg: #fbfbfb; diff --git a/backend/app/assets/stylesheets/spree/backend/plugins/_jquery_ui.scss b/backend/app/assets/stylesheets/spree/backend/plugins/_jquery_ui.scss new file mode 100644 index 00000000000..efed5f56b3b --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/plugins/_jquery_ui.scss @@ -0,0 +1,109 @@ +.ui-widget { + font-family: $headings-font-family; + font-size: $font-size-base - 1; + font-weight: 400; +} + +#ui-datepicker-div { + background-color: white; + background-image: none; + color: $gray; + border-color: $input-border; + width: auto; + padding: 20px; + + &.ui-corner-all { + border-radius: $border-radius-base; + } + + .ui-datepicker-header { + border-radius: 0; + border: none; + background-image: none; + background-color: transparent; + } + + .ui-widget-header .ui-icon { + background-color: transparent; + background-image: none; + text-indent: 0; + vertical-align: bottom !important; + width: 12px; + color: $brand-primary; + + &.ui-icon-circle-triangle-w { + @extend .glyphicon; + @extend .glyphicon-chevron-left; + } + + &.ui-icon-circle-triangle-e { + @extend .glyphicon; + @extend .glyphicon-chevron-right; + } + } + + .ui-state-hover { + border: none; + background-image: none; + background-color: transparent; + transition: color 0.2s ease-in-out; + cursor: pointer; + + span.ui-icon { + color: $brand-success; + } + } + + .ui-datepicker-next-hover { + right: 2; + } + + .ui-datepicker-prev-hover { + left: 2; + } + + .ui-datepicker-title { + font-weight: 500; + color: $gray; + } + + table.ui-datepicker-calendar { + th { + span { + text-transform: uppercase; + letter-spacing: 1px; + color: $brand-primary; + font-weight: 500; + } + } + + td { + width: 31px; + height: 29px; + transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out; + + .ui-state-default, + .ui-widget-content .ui-state-default, + .ui-widget-header .ui-state-default { + background-color: transparent; + background-image: none; + border: none; + text-align: center; + font-weight: 300; + } + + &:hover { + background-color: $gray-lighter; + } + + &:active { + background-color: $brand-success; + + a { + color: white; + } + } + } + } +} + diff --git a/backend/app/assets/stylesheets/spree/backend/plugins/_select2.scss b/backend/app/assets/stylesheets/spree/backend/plugins/_select2.scss new file mode 100644 index 00000000000..89a10cdf245 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/plugins/_select2.scss @@ -0,0 +1,127 @@ +.select2-container { + display: block; + + .select2-choice { + height: $input-height-base; + line-height: 2.2; + border-color: $input-border; + background-image: none; + background-color: $input-bg; + + &, .select2-arrow { + border-radius: $border-radius-base; + } + + .select2-arrow { + background-image: none; + background-color: $input-bg; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left-color: $input-border; + + b { + background-position: 0 7px; + } + } + + .select2-chosen { + line-height: 36px; + vertical-align: middle; + display: inline; + } + + abbr { + top: 12px; + } + } +} + +.select2-search input { + border-color: $input-border; + border-radius: $border-radius-base; +} + +.select2-drop { + &, &.select2-drop-above { + border-radius: $border-radius-base; + box-shadow: none; + } +} + +.select2-container-active { + &, & a { + box-shadow: none !important; + } +} + +.select2-dropdown-open.select2-drop-above .select2-choice, +.select2-dropdown-open.select2-drop-above .select2-choices { + background-image: none; +} + +.select2-dropdown-open.select2-drop-above .select2-choice { + border-radius: 0 0 $border-radius-base $border-radius-base; +} + +.select2-drop-above, +.select2-dropdown-open.select2-drop-above .select2-choice, +.select2-dropdown-open .select2-choice, +.select2-drop.select2-drop-above.select2-drop-active, +.select2-drop.select2-drop-active { + border-color: lighten($brand-success, 20); +} + +.select2-result-label { + color: $gray; +} + +.select2-results .select2-highlighted { + background-color: $brand-primary; + + .select2-result-label { + color: white; + } +} + +.select2-container-multi { + .select2-choices { + line-height: 1.8; + border-color: $input-border; + background-image: none; + background-color: $input-bg; + + .select2-search-choice { + @extend .label; + @extend .label-info; + + box-shadow: none; + border: none; + background-image: none; + margin: 5px; + padding: 7px; + + .select2-search-choice-close { + @extend .glyphicon; + @extend .glyphicon-remove; + + background-image: none !important; + font-size: 100%; + color: white !important; + position: absolute; + left: 7px; + top: 7px; + } + + div { + padding-left: 15px; + } + } + } + + &.select2-container-active { + .select2-choices { + box-shadow: none; + border-color: lighten($brand-success, 20); + } + } +} \ No newline at end of file diff --git a/backend/app/assets/stylesheets/spree/backend/sections/_account.scss b/backend/app/assets/stylesheets/spree/backend/sections/_account.scss new file mode 100644 index 00000000000..767a9cf9577 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/sections/_account.scss @@ -0,0 +1,20 @@ +.dropdown.user-menu { + a { + color: white !important; + font-weight: 400; + } + + .dropdown-menu { + width: 100%; + padding: 0 0; + + hr { + margin: 0 0; + } + + a { + color: $dropdown-link-color !important; + padding: 15px; + } + } +} diff --git a/backend/app/assets/stylesheets/spree/backend/shared/_base.scss b/backend/app/assets/stylesheets/spree/backend/shared/_base.scss new file mode 100644 index 00000000000..b2465265840 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/shared/_base.scss @@ -0,0 +1,25 @@ +body { + padding-top: $navbar-height + 2px; +} + +.table-wrapper { + overflow: auto; +} + +.row { + padding: 0 0 15px 0; +} + +.is-hidden { + display: none; +} + +// No-margin and no-padding helper classes +@each $side in top bottom left right { + .no-margin-#{$side} { margin-#{side}: 0; } + .no-padding-#{$side} { padding-#{side}: 0; } +} + +.alert-error { + @extend .alert-danger; +} diff --git a/backend/app/assets/stylesheets/spree/backend/shared/_forms.scss b/backend/app/assets/stylesheets/spree/backend/shared/_forms.scss new file mode 100644 index 00000000000..9eec1c07e8c --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/shared/_forms.scss @@ -0,0 +1,59 @@ +.form-control { + box-shadow: none; +} + +label { + text-transform: uppercase; + font-size: $font-size-base - 3; + letter-spacing: 1px; + color: $gray; + font-weight: 500; + + input[type="radio"], input[type="checkbox"] { + margin-top: 2px; + } +} + +span.or { + @extend label; + + padding: 0 10px; +} + +input.form-control { + &:active, &:focus { + border-color: lighten($brand-success, 20); + box-shadow: none; + } +} + +.form-group { + &.withError { + label, .formError, .required { + color: $brand-danger; + } + + input, + .select2-choice { + border-color: $brand-danger; + } + } +} + +.fullwidth-input { + display: block; + width: 100%; +} + +.help-block { + color: lighten($gray-light, 20); + font-weight: 300; +} + +.checkbox > .help-block { + margin-top: 0; +} + +.display-inline { + display: inline; +} diff --git a/backend/app/assets/stylesheets/spree/backend/spree_admin.css.scss b/backend/app/assets/stylesheets/spree/backend/spree_admin.css.scss new file mode 100644 index 00000000000..1edbb9e595a --- /dev/null +++ b/backend/app/assets/stylesheets/spree/backend/spree_admin.css.scss @@ -0,0 +1,27 @@ +@import 'global/variables'; + +@import 'bootstrap-sprockets'; +@import 'bootstrap'; + +@import 'components/sidebar'; +@import 'components/tables'; +@import 'components/labels'; +@import 'components/main'; +@import 'components/page_header'; +@import 'components/icons'; +@import 'components/buttons'; +@import 'components/wells'; +@import 'components/filters'; +@import 'components/panels'; +@import 'components/navigation'; +@import 'components/taxon_products_view'; +@import 'components/progress'; +@import 'components/spinner'; + +@import 'sections/account'; + +@import 'plugins/select2'; +@import 'plugins/jquery_ui'; + +@import 'shared/base'; +@import 'shared/forms'; diff --git a/backend/app/assets/stylesheets/spree/frontend/backend.css b/backend/app/assets/stylesheets/spree/frontend/backend.css new file mode 100644 index 00000000000..63eac109f71 --- /dev/null +++ b/backend/app/assets/stylesheets/spree/frontend/backend.css @@ -0,0 +1 @@ +/* Placeholder for backend dummy app */ diff --git a/backend/app/controllers/spree/admin/adjustments_controller.rb b/backend/app/controllers/spree/admin/adjustments_controller.rb new file mode 100644 index 00000000000..38a482f33e8 --- /dev/null +++ b/backend/app/controllers/spree/admin/adjustments_controller.rb @@ -0,0 +1,36 @@ +module Spree + module Admin + class AdjustmentsController < ResourceController + belongs_to 'spree/order', find_by: :number + + create.after :update_totals + destroy.after :update_totals + update.after :update_totals + + skip_before_action :load_resource, only: [:toggle_state, :edit, :update, :destroy] + + before_action :find_adjustment, only: [:destroy, :edit, :update] + + def index + @adjustments = @order.all_adjustments.eligible.order(created_at: :asc) + end + + private + + def find_adjustment + # Need to assign to @object here to keep ResourceController happy + @adjustment = @object = parent.all_adjustments.find(params[:id]) + end + + def update_totals + @order.reload.update_with_updater! + end + + # Override method used to create a new instance to correctly + # associate adjustment with order + def build_resource + parent.adjustments.build(order: parent) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/base_controller.rb b/backend/app/controllers/spree/admin/base_controller.rb new file mode 100644 index 00000000000..b114d7125b6 --- /dev/null +++ b/backend/app/controllers/spree/admin/base_controller.rb @@ -0,0 +1,56 @@ +module Spree + module Admin + class BaseController < Spree::BaseController + helper 'spree/admin/navigation' + layout '/spree/layouts/admin' + + before_action :authorize_admin + before_action :generate_admin_api_key + + protected + + def action + params[:action].to_sym + end + + def authorize_admin + record = if respond_to?(:model_class, true) && model_class + model_class + else + controller_name.to_sym + end + authorize! :admin, record + authorize! action, record + end + + # Need to generate an API key for a user due to some backend actions + # requiring authentication to the Spree API + def generate_admin_api_key + if (user = try_spree_current_user) && user.spree_api_key.blank? + user.generate_spree_api_key! + end + end + + def flash_message_for(object, event_sym) + resource_desc = object.class.model_name.human + resource_desc += " \"#{object.name}\"" if object.respond_to?(:name) && object.name.present? + Spree.t(event_sym, resource: resource_desc) + end + + def render_js_for_destroy + render partial: '/spree/admin/shared/destroy' + end + + def config_locale + Spree::Backend::Config[:locale] + end + + def can_not_transition_without_customer_info + unless @order.billing_address.present? + flash[:notice] = Spree.t(:fill_in_customer_info) + redirect_to edit_admin_order_customer_url(@order) + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/countries_controller.rb b/backend/app/controllers/spree/admin/countries_controller.rb new file mode 100644 index 00000000000..b07b931ea65 --- /dev/null +++ b/backend/app/controllers/spree/admin/countries_controller.rb @@ -0,0 +1,11 @@ +module Spree + module Admin + class CountriesController < ResourceController + private + + def collection + super.order(:name) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/customer_returns_controller.rb b/backend/app/controllers/spree/admin/customer_returns_controller.rb new file mode 100644 index 00000000000..365676a9aed --- /dev/null +++ b/backend/app/controllers/spree/admin/customer_returns_controller.rb @@ -0,0 +1,68 @@ +module Spree + module Admin + class CustomerReturnsController < ResourceController + belongs_to 'spree/order', find_by: :number + + before_action :parent # ensure order gets loaded to support our pseudo parent-child relationship + before_action :load_form_data, only: [:new, :edit] + + create.before :build_return_items_from_params + create.fails :load_form_data + + def edit + returned_items = @customer_return.return_items + @pending_return_items = returned_items.select(&:pending?) + @accepted_return_items = returned_items.select(&:accepted?) + @rejected_return_items = returned_items.select(&:rejected?) + @manual_intervention_return_items = returned_items.select(&:manual_intervention_required?) + @pending_reimbursements = @customer_return.reimbursements.select(&:pending?) + + super + end + + private + + def location_after_save + url_for([:edit, :admin, @order, @customer_return]) + end + + def build_resource + Spree::CustomerReturn.new + end + + def find_resource + Spree::CustomerReturn.accessible_by(current_ability, :read).find(params[:id]) + end + + def collection + parent # trigger loading the order + @collection ||= Spree::ReturnItem. + accessible_by(current_ability, :read). + where(inventory_unit_id: @order.inventory_units.pluck(:id)). + map(&:customer_return).uniq.compact + @customer_returns = @collection + end + + def load_form_data + return_items = @order.inventory_units.map(&:current_or_new_return_item).reject(&:customer_return_id) + @rma_return_items = return_items.select(&:return_authorization_id) + end + + def permitted_resource_params + @permitted_resource_params ||= params.require('customer_return').permit(permitted_customer_return_attributes) + end + + def build_return_items_from_params + return_items_params = permitted_resource_params.delete(:return_items_attributes).values + + @customer_return.return_items = return_items_params.map do |item_params| + next unless item_params.delete('returned') == '1' + + return_item = item_params[:id] ? Spree::ReturnItem.find(item_params[:id]) : Spree::ReturnItem.new + return_item.attributes = item_params + return_item + end.compact + end + end + end +end diff --git a/backend/app/controllers/spree/admin/general_settings_controller.rb b/backend/app/controllers/spree/admin/general_settings_controller.rb new file mode 100644 index 00000000000..e70441b5b3b --- /dev/null +++ b/backend/app/controllers/spree/admin/general_settings_controller.rb @@ -0,0 +1,28 @@ +module Spree + module Admin + class GeneralSettingsController < Spree::Admin::BaseController + include Spree::Backend::Callbacks + + def edit + @preferences_security = [] + end + + def update + params.each do |name, value| + next unless Spree::Config.has_preference? name + + Spree::Config[name] = value + end + + flash[:success] = Spree.t(:successfully_updated, resource: Spree.t(:general_settings)) + redirect_to edit_admin_general_settings_path + end + + def clear_cache + Rails.cache.clear + invoke_callbacks(:clear_cache, :after) + head :no_content + end + end + end +end diff --git a/backend/app/controllers/spree/admin/images_controller.rb b/backend/app/controllers/spree/admin/images_controller.rb new file mode 100644 index 00000000000..37d4448b2ab --- /dev/null +++ b/backend/app/controllers/spree/admin/images_controller.rb @@ -0,0 +1,50 @@ +module Spree + module Admin + class ImagesController < ResourceController + before_action :load_edit_data, except: :index + before_action :load_index_data, only: :index + + create.before :set_viewable + update.before :set_viewable + + private + + def location_after_destroy + admin_product_images_url(@product) + end + + def location_after_save + admin_product_images_url(@product) + end + + def load_index_data + @product = Product.friendly.includes(*variant_index_includes).find(params[:product_id]) + end + + def load_edit_data + @product = Product.friendly.includes(*variant_edit_includes).find(params[:product_id]) + @variants = @product.variants.map do |variant| + [variant.sku_and_options_text, variant.id] + end + @variants.insert(0, [Spree.t(:all), @product.master.id]) + end + + def set_viewable + @image.viewable_type = 'Spree::Variant' + @image.viewable_id = params[:image][:viewable_id] + end + + def variant_index_includes + [ + variant_images: [viewable: { option_values: :option_type }] + ] + end + + def variant_edit_includes + [ + variants_including_master: { option_values: :option_type, images: :viewable } + ] + end + end + end +end diff --git a/backend/app/controllers/spree/admin/log_entries_controller.rb b/backend/app/controllers/spree/admin/log_entries_controller.rb new file mode 100644 index 00000000000..728f5c3b5e5 --- /dev/null +++ b/backend/app/controllers/spree/admin/log_entries_controller.rb @@ -0,0 +1,18 @@ +module Spree + module Admin + class LogEntriesController < Spree::Admin::BaseController + before_action :find_order_and_payment + + def index + @log_entries = @payment.log_entries + end + + private + + def find_order_and_payment + @order = Spree::Order.find_by!(number: params[:order_id]) + @payment = @order.payments.find_by!(number: params[:payment_id]) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/option_types_controller.rb b/backend/app/controllers/spree/admin/option_types_controller.rb new file mode 100644 index 00000000000..6c8bdabfd51 --- /dev/null +++ b/backend/app/controllers/spree/admin/option_types_controller.rb @@ -0,0 +1,36 @@ +module Spree + module Admin + class OptionTypesController < ResourceController + before_action :setup_new_option_value, only: :edit + + def update_values_positions + ApplicationRecord.transaction do + params[:positions].each do |id, index| + Spree::OptionValue.where(id: id).update_all(position: index) + end + end + + respond_to do |format| + format.html { redirect_to admin_product_variants_url(params[:product_id]) } + format.js { render plain: 'Ok' } + end + end + + protected + + def location_after_save + if @option_type.created_at == @option_type.updated_at + edit_admin_option_type_url(@option_type) + else + admin_option_types_url + end + end + + private + + def setup_new_option_value + @option_type.option_values.build if @option_type.option_values.empty? + end + end + end +end diff --git a/backend/app/controllers/spree/admin/option_values_controller.rb b/backend/app/controllers/spree/admin/option_values_controller.rb new file mode 100644 index 00000000000..5c44d48fe4b --- /dev/null +++ b/backend/app/controllers/spree/admin/option_values_controller.rb @@ -0,0 +1,11 @@ +module Spree + module Admin + class OptionValuesController < Spree::Admin::BaseController + def destroy + option_value = Spree::OptionValue.find(params[:id]) + option_value.destroy + render plain: nil + end + end + end +end diff --git a/backend/app/controllers/spree/admin/orders/customer_details_controller.rb b/backend/app/controllers/spree/admin/orders/customer_details_controller.rb new file mode 100644 index 00000000000..5204f3f4c7a --- /dev/null +++ b/backend/app/controllers/spree/admin/orders/customer_details_controller.rb @@ -0,0 +1,73 @@ +module Spree + module Admin + module Orders + class CustomerDetailsController < Spree::Admin::BaseController + before_action :load_order + before_action :load_user, only: :update, unless: :guest_checkout? + + def show + edit + render action: :edit + end + + def edit + country_id = Address.default.country.id + @order.build_bill_address(country_id: country_id) if @order.bill_address.nil? + @order.build_ship_address(country_id: country_id) if @order.ship_address.nil? + + @order.bill_address.country_id = country_id if @order.bill_address.country.nil? + @order.ship_address.country_id = country_id if @order.ship_address.country.nil? + end + + def update + if @order.update_attributes(order_params) + @order.associate_user!(@user, @order.email.blank?) unless guest_checkout? + @order.next if @order.address? + @order.refresh_shipment_rates(Spree::ShippingMethod::DISPLAY_ON_BACK_END) + + if @order.errors.empty? + flash[:success] = Spree.t('customer_details_updated') + redirect_to edit_admin_order_url(@order) + else + render action: :edit + end + else + render action: :edit + end + end + + private + + def order_params + params.require(:order).permit( + :email, :user_id, :use_billing, + bill_address_attributes: permitted_address_attributes, + ship_address_attributes: permitted_address_attributes + ) + end + + def load_order + @order = Order.includes(:adjustments).find_by!(number: params[:order_id]) + end + + def model_class + Spree::Order + end + + def load_user + @user = (Spree.user_class.find_by(id: order_params[:user_id]) || + Spree.user_class.find_by(email: order_params[:email])) + + unless @user + flash.now[:error] = Spree.t(:user_not_found) + render :edit + end + end + + def guest_checkout? + params[:guest_checkout] == 'true' + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/orders_controller.rb b/backend/app/controllers/spree/admin/orders_controller.rb new file mode 100644 index 00000000000..4edd231c481 --- /dev/null +++ b/backend/app/controllers/spree/admin/orders_controller.rb @@ -0,0 +1,169 @@ +module Spree + module Admin + class OrdersController < Spree::Admin::BaseController + before_action :initialize_order_events + before_action :load_order, only: [:edit, :update, :cancel, :resume, :approve, :resend, :open_adjustments, :close_adjustments, :cart, :store, :set_store] + + respond_to :html + + def index + params[:q] ||= {} + params[:q][:completed_at_not_null] ||= '1' if Spree::Config[:show_only_complete_orders_by_default] + @show_only_completed = params[:q][:completed_at_not_null] == '1' + params[:q][:s] ||= @show_only_completed ? 'completed_at desc' : 'created_at desc' + params[:q][:completed_at_not_null] = '' unless @show_only_completed + + # As date params are deleted if @show_only_completed, store + # the original date so we can restore them into the params + # after the search + created_at_gt = params[:q][:created_at_gt] + created_at_lt = params[:q][:created_at_lt] + + params[:q].delete(:inventory_units_shipment_id_null) if params[:q][:inventory_units_shipment_id_null] == '0' + + if params[:q][:created_at_gt].present? + params[:q][:created_at_gt] = begin + Time.zone.parse(params[:q][:created_at_gt]).beginning_of_day + rescue StandardError + '' + end + end + + if params[:q][:created_at_lt].present? + params[:q][:created_at_lt] = begin + Time.zone.parse(params[:q][:created_at_lt]).end_of_day + rescue StandardError + '' + end + end + + if @show_only_completed + params[:q][:completed_at_gt] = params[:q].delete(:created_at_gt) + params[:q][:completed_at_lt] = params[:q].delete(:created_at_lt) + end + + @search = Spree::Order.preload(:user).accessible_by(current_ability, :index).ransack(params[:q]) + + # lazy loading other models here (via includes) may result in an invalid query + # e.g. SELECT DISTINCT DISTINCT "spree_orders".id, "spree_orders"."created_at" AS alias_0 FROM "spree_orders" + # see https://github.com/spree/spree/pull/3919 + @orders = @search.result(distinct: true). + page(params[:page]). + per(params[:per_page] || Spree::Config[:admin_orders_per_page]) + + # Restore dates + params[:q][:created_at_gt] = created_at_gt + params[:q][:created_at_lt] = created_at_lt + end + + def new + @order = Spree::Order.create(order_params) + redirect_to cart_admin_order_url(@order) + end + + def edit + can_not_transition_without_customer_info + + @order.refresh_shipment_rates(ShippingMethod::DISPLAY_ON_BACK_END) unless @order.completed? + end + + def cart + @order.refresh_shipment_rates(ShippingMethod::DISPLAY_ON_BACK_END) unless @order.completed? + + if @order.shipments.shipped.exists? + redirect_to edit_admin_order_url(@order) + end + end + + def store + @stores = Spree::Store.all + end + + def update + if @order.update_attributes(params[:order]) && @order.line_items.present? + @order.update_with_updater! + unless @order.completed? + # Jump to next step if order is not completed. + redirect_to admin_order_customer_path(@order) and return + end + else + @order.errors.add(:line_items, Spree.t('errors.messages.blank')) if @order.line_items.empty? + end + + render action: :edit + end + + def cancel + @order.canceled_by(try_spree_current_user) + flash[:success] = Spree.t(:order_canceled) + redirect_back fallback_location: spree.edit_admin_order_url(@order) + end + + def resume + @order.resume! + flash[:success] = Spree.t(:order_resumed) + redirect_back fallback_location: spree.edit_admin_order_url(@order) + end + + def approve + @order.approved_by(try_spree_current_user) + flash[:success] = Spree.t(:order_approved) + redirect_back fallback_location: spree.edit_admin_order_url(@order) + end + + def resend + OrderMailer.confirm_email(@order.id, true).deliver_later + flash[:success] = Spree.t(:order_email_resent) + + redirect_back fallback_location: spree.edit_admin_order_url(@order) + end + + def open_adjustments + adjustments = @order.all_adjustments.finalized + adjustments.update_all(state: 'open') + flash[:success] = Spree.t(:all_adjustments_opened) + + respond_with(@order) { |format| format.html { redirect_back fallback_location: spree.admin_order_adjustments_url(@order) } } + end + + def close_adjustments + adjustments = @order.all_adjustments.not_finalized + adjustments.update_all(state: 'closed') + flash[:success] = Spree.t(:all_adjustments_closed) + + respond_with(@order) { |format| format.html { redirect_back fallback_location: spree.admin_order_adjustments_url(@order) } } + end + + def set_store + if @order.update_attributes(store_id: params[:order][:store_id]) + flash[:success] = flash_message_for(@order, :successfully_updated) + else + flash[:error] = @order.errors.full_messages.join(', ') + end + + redirect_to store_admin_order_url(@order) + end + + private + + def order_params + params[:created_by_id] = try_spree_current_user.try(:id) + params.permit(:created_by_id, :user_id, :store_id) + end + + def load_order + @order = Spree::Order.includes(:adjustments).find_by!(number: params[:id]) + authorize! action, @order + end + + # Used for extensions which need to provide their own custom event links on the order details view. + def initialize_order_events + @order_events = %w{approve cancel resume} + end + + def model_class + Spree::Order + end + end + end +end diff --git a/backend/app/controllers/spree/admin/payment_methods_controller.rb b/backend/app/controllers/spree/admin/payment_methods_controller.rb new file mode 100644 index 00000000000..45163fe08d2 --- /dev/null +++ b/backend/app/controllers/spree/admin/payment_methods_controller.rb @@ -0,0 +1,80 @@ +module Spree + module Admin + class PaymentMethodsController < ResourceController + skip_before_action :load_resource, only: :create + before_action :load_data + before_action :validate_payment_method_provider, only: :create + + respond_to :html + + def create + @payment_method = params[:payment_method].delete(:type).constantize.new(payment_method_params) + @object = @payment_method + invoke_callbacks(:create, :before) + if @payment_method.save + invoke_callbacks(:create, :after) + flash[:success] = Spree.t(:successfully_created, resource: Spree.t(:payment_method)) + redirect_to edit_admin_payment_method_path(@payment_method) + else + invoke_callbacks(:create, :fails) + respond_with(@payment_method) + end + end + + def update + invoke_callbacks(:update, :before) + payment_method_type = params[:payment_method].delete(:type) + if @payment_method['type'].to_s != payment_method_type + @payment_method.update_columns( + type: payment_method_type, + updated_at: Time.current + ) + @payment_method = PaymentMethod.find(params[:id]) + end + + attributes = payment_method_params.merge(preferences_params) + attributes.each do |k, _v| + attributes.delete(k) if k.include?('password') && attributes[k].blank? + end + + if @payment_method.update_attributes(attributes) + invoke_callbacks(:update, :after) + flash[:success] = Spree.t(:successfully_updated, resource: Spree.t(:payment_method)) + redirect_to edit_admin_payment_method_path(@payment_method) + else + invoke_callbacks(:update, :fails) + respond_with(@payment_method) + end + end + + private + + def collection + @collection = super.order(position: :asc) + end + + def load_data + @providers = Gateway.providers.sort_by(&:name) + end + + def validate_payment_method_provider + valid_payment_methods = Rails.application.config.spree.payment_methods.map(&:to_s) + unless valid_payment_methods.include?(params[:payment_method][:type]) + flash[:error] = Spree.t(:invalid_payment_provider) + redirect_to new_admin_payment_method_path + end + end + + def payment_method_params + params.require(:payment_method).permit! + end + + def preferences_params + key = ActiveModel::Naming.param_key(@payment_method) + return {} unless params.key? key + + params.require(key).permit! + end + end + end +end diff --git a/backend/app/controllers/spree/admin/payments_controller.rb b/backend/app/controllers/spree/admin/payments_controller.rb new file mode 100644 index 00000000000..c7430e92bdd --- /dev/null +++ b/backend/app/controllers/spree/admin/payments_controller.rb @@ -0,0 +1,117 @@ +module Spree + module Admin + class PaymentsController < Spree::Admin::BaseController + include Spree::Backend::Callbacks + + before_action :load_order, only: [:create, :new, :index, :fire] + before_action :load_payment, except: [:create, :new, :index] + before_action :load_data + before_action :can_not_transition_without_customer_info + + respond_to :html + + def index + @payments = @order.payments.includes(refunds: :reason) + @refunds = @payments.flat_map(&:refunds) + redirect_to new_admin_order_payment_url(@order) if @payments.empty? + end + + def new + # Move order to payment state in order to capture tax generated on shipments + @order.next if @order.can_go_to_state?('payment') + @payment = @order.payments.build + end + + def create + invoke_callbacks(:create, :before) + + begin + if @payment_method.store_credit? + Spree::Dependencies.checkout_add_store_credit_service.constantize.call(order: @order) + payments = @order.payments.store_credits.valid + else + @payment ||= @order.payments.build(object_params) + if @payment.payment_method.source_required? && params[:card].present? && params[:card] != 'new' + @payment.source = @payment.payment_method.payment_source_class.find_by(id: params[:card]) + end + @payment.save + payments = [@payment] + end + + if payments && (saved_payments = payments.select &:persisted?).any? + invoke_callbacks(:create, :after) + + # Transition order as far as it will go. + while @order.next; end + # If "@order.next" didn't trigger payment processing already (e.g. if the order was + # already complete) then trigger it manually now + + saved_payments.each { |payment| payment.process! if payment.reload.checkout? && @order.complete? } + flash[:success] = flash_message_for(saved_payments.first, :successfully_created) + redirect_to admin_order_payments_path(@order) + else + @payment ||= @order.payments.build(object_params) + invoke_callbacks(:create, :fails) + flash[:error] = Spree.t(:payment_could_not_be_created) + render :new + end + rescue Spree::Core::GatewayError => e + invoke_callbacks(:create, :fails) + flash[:error] = e.message.to_s + redirect_to new_admin_order_payment_path(@order) + end + end + + def fire + return unless event = params[:e] and @payment.payment_source + + # Because we have a transition method also called void, we do this to avoid conflicts. + event = 'void_transaction' if event == 'void' + if @payment.send("#{event}!") + flash[:success] = Spree.t(:payment_updated) + else + flash[:error] = Spree.t(:cannot_perform_operation) + end + rescue Spree::Core::GatewayError => ge + flash[:error] = ge.message.to_s + ensure + redirect_to admin_order_payments_path(@order) + end + + private + + def object_params + if params[:payment] and params[:payment_source] and source_params = params.delete(:payment_source)[params[:payment][:payment_method_id]] + params[:payment][:source_attributes] = source_params + end + + params.require(:payment).permit(permitted_payment_attributes) + end + + def load_data + @amount = params[:amount] || load_order.total + @payment_methods = @order.collect_backend_payment_methods + if @payment&.payment_method + @payment_method = @payment.payment_method + else + @payment_method = @payment_methods.find { |payment_method| payment_method.id == params[:payment][:payment_method_id].to_i } if params[:payment] + @payment_method ||= @payment_methods.first + end + end + + def load_order + @order = Order.find_by!(number: params[:order_id]) + authorize! action, @order + @order + end + + def load_payment + @payment = Payment.find_by!(number: params[:id]) + end + + def model_class + Spree::Payment + end + end + end +end diff --git a/backend/app/controllers/spree/admin/product_properties_controller.rb b/backend/app/controllers/spree/admin/product_properties_controller.rb new file mode 100644 index 00000000000..cad347b7c1f --- /dev/null +++ b/backend/app/controllers/spree/admin/product_properties_controller.rb @@ -0,0 +1,19 @@ +module Spree + module Admin + class ProductPropertiesController < ResourceController + belongs_to 'spree/product', find_by: :slug + before_action :find_properties + before_action :setup_property, only: :index + + private + + def find_properties + @properties = Spree::Property.pluck(:name) + end + + def setup_property + @product.product_properties.build + end + end + end +end diff --git a/backend/app/controllers/spree/admin/products_controller.rb b/backend/app/controllers/spree/admin/products_controller.rb new file mode 100644 index 00000000000..ab292d7ed44 --- /dev/null +++ b/backend/app/controllers/spree/admin/products_controller.rb @@ -0,0 +1,162 @@ +module Spree + module Admin + class ProductsController < ResourceController + helper 'spree/products' + + before_action :load_data, except: :index + create.before :create_before + update.before :update_before + helper_method :clone_object_url + + def show + session[:return_to] ||= request.referer + redirect_to action: :edit + end + + def index + session[:return_to] = request.url + respond_with(@collection) + end + + def update + if params[:product][:taxon_ids].present? + params[:product][:taxon_ids] = params[:product][:taxon_ids].split(',') + end + if params[:product][:option_type_ids].present? + params[:product][:option_type_ids] = params[:product][:option_type_ids].split(',') + end + invoke_callbacks(:update, :before) + if @object.update_attributes(permitted_resource_params) + invoke_callbacks(:update, :after) + flash[:success] = flash_message_for(@object, :successfully_updated) + respond_with(@object) do |format| + format.html { redirect_to location_after_save } + format.js { render layout: false } + end + else + # Stops people submitting blank slugs, causing errors when they try to + # update the product again + @product.slug = @product.slug_was if @product.slug.blank? + invoke_callbacks(:update, :fails) + respond_with(@object) + end + end + + def destroy + @product = Product.friendly.find(params[:id]) + + begin + # TODO: why is @product.destroy raising ActiveRecord::RecordNotDestroyed instead of failing with false result + if @product.destroy + flash[:success] = Spree.t('notice_messages.product_deleted') + else + flash[:error] = Spree.t('notice_messages.product_not_deleted', error: @product.errors.full_messages.to_sentence) + end + rescue ActiveRecord::RecordNotDestroyed => e + flash[:error] = Spree.t('notice_messages.product_not_deleted', error: e.message) + end + + respond_with(@product) do |format| + format.html { redirect_to collection_url } + format.js { render_js_for_destroy } + end + end + + def clone + @new = @product.duplicate + + if @new.persisted? + flash[:success] = Spree.t('notice_messages.product_cloned') + redirect_to edit_admin_product_url(@new) + else + flash[:error] = Spree.t('notice_messages.product_not_cloned', error: @new.errors.full_messages.to_sentence) + redirect_to admin_products_url + end + rescue ActiveRecord::RecordInvalid => e + # Handle error on uniqueness validation on product fields + flash[:error] = Spree.t('notice_messages.product_not_cloned', error: e.message) + redirect_to admin_products_url + end + + def stock + @variants = @product.variants.includes(*variant_stock_includes) + @variants = [@product.master] if @variants.empty? + @stock_locations = StockLocation.accessible_by(current_ability, :read) + if @stock_locations.empty? + flash[:error] = Spree.t(:stock_management_requires_a_stock_location) + redirect_to admin_stock_locations_path + end + end + + protected + + def find_resource + Product.with_deleted.friendly.find(params[:id]) + end + + def location_after_save + spree.edit_admin_product_url(@product) + end + + def load_data + @taxons = Taxon.order(:name) + @option_types = OptionType.order(:name) + @tax_categories = TaxCategory.order(:name) + @shipping_categories = ShippingCategory.order(:name) + end + + def collection + return @collection if @collection.present? + + params[:q] ||= {} + params[:q][:deleted_at_null] ||= '1' + params[:q][:not_discontinued] ||= '1' + + params[:q][:s] ||= 'name asc' + @collection = super + # Don't delete params[:q][:deleted_at_null] here because it is used in view to check the + # checkbox for 'q[deleted_at_null]'. This also messed with pagination when deleted_at_null is checked. + if params[:q][:deleted_at_null] == '0' + @collection = @collection.with_deleted + end + # @search needs to be defined as this is passed to search_form_for + # Temporarily remove params[:q][:deleted_at_null] from params[:q] to ransack products. + # This is to include all products and not just deleted products. + @search = @collection.ransack(params[:q].reject { |k, _v| k.to_s == 'deleted_at_null' }) + @collection = @search.result. + includes(product_includes). + page(params[:page]). + per(params[:per_page] || Spree::Config[:admin_products_per_page]) + @collection + end + + def create_before + return if params[:product][:prototype_id].blank? + + @prototype = Spree::Prototype.find(params[:product][:prototype_id]) + end + + def update_before + # note: we only reset the product properties if we're receiving a post + # from the form on that tab + return unless params[:clear_product_properties] + + params[:product] ||= {} + end + + def product_includes + [{ variants: [:images], master: [:images, :default_price] }] + end + + def clone_object_url(resource) + clone_admin_product_url resource + end + + private + + def variant_stock_includes + [:images, stock_items: :stock_location, option_values: :option_type] + end + end + end +end diff --git a/backend/app/controllers/spree/admin/promotion_actions_controller.rb b/backend/app/controllers/spree/admin/promotion_actions_controller.rb new file mode 100644 index 00000000000..8c74f33eea4 --- /dev/null +++ b/backend/app/controllers/spree/admin/promotion_actions_controller.rb @@ -0,0 +1,45 @@ +class Spree::Admin::PromotionActionsController < Spree::Admin::BaseController + before_action :load_promotion, only: [:create, :destroy] + before_action :validate_promotion_action_type, only: :create + + def create + @calculators = Spree::Promotion::Actions::CreateAdjustment.calculators + @promotion_action = params[:action_type].constantize.new(params[:promotion_action]) + @promotion_action.promotion = @promotion + if @promotion_action.save + flash[:success] = Spree.t(:successfully_created, resource: Spree.t(:promotion_action)) + end + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + + def destroy + @promotion_action = @promotion.promotion_actions.find(params[:id]) + if @promotion_action.destroy + flash[:success] = Spree.t(:successfully_removed, resource: Spree.t(:promotion_action)) + end + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + + private + + def load_promotion + @promotion = Spree::Promotion.find(params[:promotion_id]) + end + + def validate_promotion_action_type + valid_promotion_action_types = Rails.application.config.spree.promotions.actions.map(&:to_s) + unless valid_promotion_action_types.include?(params[:action_type]) + flash[:error] = Spree.t(:invalid_promotion_action) + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + end +end diff --git a/backend/app/controllers/spree/admin/promotion_categories_controller.rb b/backend/app/controllers/spree/admin/promotion_categories_controller.rb new file mode 100644 index 00000000000..024be2974de --- /dev/null +++ b/backend/app/controllers/spree/admin/promotion_categories_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class PromotionCategoriesController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/promotion_rules_controller.rb b/backend/app/controllers/spree/admin/promotion_rules_controller.rb new file mode 100644 index 00000000000..380fc22767b --- /dev/null +++ b/backend/app/controllers/spree/admin/promotion_rules_controller.rb @@ -0,0 +1,54 @@ +class Spree::Admin::PromotionRulesController < Spree::Admin::BaseController + helper 'spree/admin/promotion_rules' + + before_action :load_promotion, only: [:create, :destroy] + before_action :validate_promotion_rule_type, only: :create + + def create + @promotion_rule = @promotion_rule_type.new(promotion_rule_params) + @promotion_rule.promotion = @promotion + if @promotion_rule.save + flash[:success] = Spree.t(:successfully_created, resource: Spree.t(:promotion_rule)) + end + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + + def destroy + @promotion_rule = @promotion.promotion_rules.find(params[:id]) + if @promotion_rule.destroy + flash[:success] = Spree.t(:successfully_removed, resource: Spree.t(:promotion_rule)) + end + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + + private + + def load_promotion + @promotion = Spree::Promotion.find(params[:promotion_id]) + end + + def validate_promotion_rule_type + requested_type = params[:promotion_rule].delete(:type) + promotion_rule_types = Rails.application.config.spree.promotions.rules + @promotion_rule_type = promotion_rule_types.detect do |klass| + klass.name == requested_type + end + unless @promotion_rule_type + flash[:error] = Spree.t(:invalid_promotion_rule) + respond_to do |format| + format.html { redirect_to spree.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + end + + def promotion_rule_params + params[:promotion_rule].permit! + end +end diff --git a/backend/app/controllers/spree/admin/promotions_controller.rb b/backend/app/controllers/spree/admin/promotions_controller.rb new file mode 100644 index 00000000000..1a54ebf9068 --- /dev/null +++ b/backend/app/controllers/spree/admin/promotions_controller.rb @@ -0,0 +1,53 @@ +module Spree + module Admin + class PromotionsController < ResourceController + before_action :load_data, except: :clone + + helper 'spree/admin/promotion_rules' + + def clone + promotion = Spree::Promotion.find(params[:id]) + duplicator = Spree::PromotionHandler::PromotionDuplicator.new(promotion) + + @new_promo = duplicator.duplicate + + if @new_promo.errors.empty? + flash[:success] = Spree.t('promotion_cloned') + redirect_to edit_admin_promotion_url(@new_promo) + else + flash[:error] = Spree.t('promotion_not_cloned', error: @new_promo.errors.full_messages.to_sentence) + redirect_to admin_promotions_url(@new_promo) + end + end + + protected + + def location_after_save + spree.edit_admin_promotion_url(@promotion) + end + + def load_data + @calculators = Rails.application.config.spree.calculators.promotion_actions_create_adjustments + @promotion_categories = Spree::PromotionCategory.order(:name) + end + + def collection + return @collection if defined?(@collection) + + params[:q] ||= HashWithIndifferentAccess.new + params[:q][:s] ||= 'id desc' + + @collection = super + @search = @collection.ransack(params[:q]) + @collection = @search.result(distinct: true). + includes(promotion_includes). + page(params[:page]). + per(params[:per_page] || Spree::Config[:admin_promotions_per_page]) + end + + def promotion_includes + [:promotion_actions] + end + end + end +end diff --git a/backend/app/controllers/spree/admin/properties_controller.rb b/backend/app/controllers/spree/admin/properties_controller.rb new file mode 100644 index 00000000000..a1280ac35f2 --- /dev/null +++ b/backend/app/controllers/spree/admin/properties_controller.rb @@ -0,0 +1,24 @@ +module Spree + module Admin + class PropertiesController < ResourceController + def index + respond_with(@collection) + end + + private + + def collection + return @collection if @collection.present? + + # params[:q] can be blank upon pagination + params[:q] = {} if params[:q].blank? + + @collection = super + @search = @collection.ransack(params[:q]) + @collection = @search.result. + page(params[:page]). + per(Spree::Config[:admin_properties_per_page]) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/prototypes_controller.rb b/backend/app/controllers/spree/admin/prototypes_controller.rb new file mode 100644 index 00000000000..4ec8e0d575b --- /dev/null +++ b/backend/app/controllers/spree/admin/prototypes_controller.rb @@ -0,0 +1,26 @@ +module Spree + module Admin + class PrototypesController < ResourceController + def show + if request.xhr? + render layout: false + else + redirect_to admin_prototypes_path + end + end + + def available + @prototypes = Prototype.order('name asc') + respond_with(@prototypes) do |format| + format.html { render layout: !request.xhr? } + format.js + end + end + + def select + @prototype ||= Prototype.find(params[:id]) + @prototype_properties = @prototype.properties + end + end + end +end diff --git a/backend/app/controllers/spree/admin/refund_reasons_controller.rb b/backend/app/controllers/spree/admin/refund_reasons_controller.rb new file mode 100644 index 00000000000..e08a6a69ce3 --- /dev/null +++ b/backend/app/controllers/spree/admin/refund_reasons_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class RefundReasonsController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/refunds_controller.rb b/backend/app/controllers/spree/admin/refunds_controller.rb new file mode 100644 index 00000000000..cfaa2c3ad08 --- /dev/null +++ b/backend/app/controllers/spree/admin/refunds_controller.rb @@ -0,0 +1,38 @@ +module Spree + module Admin + class RefundsController < ResourceController + belongs_to 'spree/payment', find_by: :number + before_action :load_order + + helper_method :refund_reasons + + rescue_from Spree::Core::GatewayError, with: :spree_core_gateway_error + + private + + def location_after_save + admin_order_payments_path(@payment.order) + end + + def load_order + # the spree/admin/shared/order_tabs partial expects the @order instance variable to be set + @order = @payment.order if @payment + end + + def refund_reasons + @refund_reasons ||= RefundReason.active.all + end + + def build_resource + super.tap do |refund| + refund.amount = refund.payment.credit_allowed + end + end + + def spree_core_gateway_error(error) + flash[:error] = error.message + render :new + end + end + end +end diff --git a/backend/app/controllers/spree/admin/reimbursement_types_controller.rb b/backend/app/controllers/spree/admin/reimbursement_types_controller.rb new file mode 100644 index 00000000000..52923d89f28 --- /dev/null +++ b/backend/app/controllers/spree/admin/reimbursement_types_controller.rb @@ -0,0 +1,32 @@ +module Spree + module Admin + class ReimbursementTypesController < ResourceController + def update + invoke_callbacks(:update, :before) + if @object.update_attributes(permitted_resource_params_for_update) + invoke_callbacks(:update, :after) + respond_with(@object) do |format| + format.html do + flash[:success] = flash_message_for(@object, :successfully_updated) + redirect_to location_after_save + end + format.js { render layout: false } + end + else + invoke_callbacks(:update, :fails) + respond_with(@object) do |format| + format.html { render action: :edit } + format.js { render layout: false } + end + end + end + + private + + def permitted_resource_params_for_update + params_hash = @object.type.underscore.remove('spree/').tr('/', '_') + params.require(params_hash.to_s).permit(:name, :active, :mutable) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/reimbursements_controller.rb b/backend/app/controllers/spree/admin/reimbursements_controller.rb new file mode 100644 index 00000000000..f75ce192e7d --- /dev/null +++ b/backend/app/controllers/spree/admin/reimbursements_controller.rb @@ -0,0 +1,45 @@ +module Spree + module Admin + class ReimbursementsController < ResourceController + belongs_to 'spree/order', find_by: :number + + before_action :load_simulated_refunds, only: :edit + + rescue_from Spree::Core::GatewayError, with: :spree_core_gateway_error + + def perform + @reimbursement.perform! + redirect_to location_after_save + end + + private + + def build_resource + if params[:build_from_customer_return_id].present? + customer_return = CustomerReturn.find(params[:build_from_customer_return_id]) + + Reimbursement.build_from_customer_return(customer_return) + else + super + end + end + + def location_after_save + if @reimbursement.reimbursed? + admin_order_reimbursement_path(parent, @reimbursement) + else + edit_admin_order_reimbursement_path(parent, @reimbursement) + end + end + + def load_simulated_refunds + @reimbursement_objects = @reimbursement.simulate + end + + def spree_core_gateway_error(error) + flash[:error] = error.message + redirect_to edit_admin_order_reimbursement_path(parent, @reimbursement) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/reports_controller.rb b/backend/app/controllers/spree/admin/reports_controller.rb new file mode 100644 index 00000000000..39b30d429bb --- /dev/null +++ b/backend/app/controllers/spree/admin/reports_controller.rb @@ -0,0 +1,72 @@ +module Spree + module Admin + class ReportsController < Spree::Admin::BaseController + respond_to :html + + class << self + def available_reports + @@available_reports + end + + def add_available_report!(report_key, report_description_key = nil) + if report_description_key.nil? + report_description_key = "#{report_key}_description" + end + @@available_reports[report_key] = { name: Spree.t(report_key), description: Spree.t(report_description_key) } + end + end + + def initialize + super + ReportsController.add_available_report!(:sales_total) + end + + def index + @reports = ReportsController.available_reports + end + + def sales_total + params[:q] = {} unless params[:q] + + params[:q][:completed_at_gt] = if params[:q][:completed_at_gt].blank? + Time.zone.now.beginning_of_month + else + begin + Time.zone.parse(params[:q][:completed_at_gt]).beginning_of_day + rescue StandardError + Time.zone.now.beginning_of_month + end + end + + if params[:q] && !params[:q][:completed_at_lt].blank? + params[:q][:completed_at_lt] = begin + Time.zone.parse(params[:q][:completed_at_lt]).end_of_day + rescue StandardError + '' + end + end + + params[:q][:s] ||= 'completed_at desc' + + @search = Order.complete.ransack(params[:q]) + @orders = @search.result + + @totals = {} + @orders.each do |order| + @totals[order.currency] = { item_total: ::Money.new(0, order.currency), adjustment_total: ::Money.new(0, order.currency), sales_total: ::Money.new(0, order.currency) } unless @totals[order.currency] + @totals[order.currency][:item_total] += order.display_item_total.money + @totals[order.currency][:adjustment_total] += order.display_adjustment_total.money + @totals[order.currency][:sales_total] += order.display_total.money + end + end + + private + + def model_class + Spree::Admin::ReportsController + end + + @@available_reports = {} + end + end +end diff --git a/backend/app/controllers/spree/admin/resource_controller.rb b/backend/app/controllers/spree/admin/resource_controller.rb new file mode 100644 index 00000000000..69169fc3c6d --- /dev/null +++ b/backend/app/controllers/spree/admin/resource_controller.rb @@ -0,0 +1,254 @@ +class Spree::Admin::ResourceController < Spree::Admin::BaseController + include Spree::Backend::Callbacks + + helper_method :new_object_url, :edit_object_url, :object_url, :collection_url + before_action :load_resource, except: :update_positions + rescue_from ActiveRecord::RecordNotFound, with: :resource_not_found + + respond_to :html + + def new + invoke_callbacks(:new_action, :before) + respond_with(@object) do |format| + format.html { render layout: !request.xhr? } + format.js { render layout: false } if request.xhr? + end + end + + def edit + respond_with(@object) do |format| + format.html { render layout: !request.xhr? } + format.js { render layout: false } if request.xhr? + end + end + + def update + invoke_callbacks(:update, :before) + if @object.update_attributes(permitted_resource_params) + invoke_callbacks(:update, :after) + respond_with(@object) do |format| + format.html do + flash[:success] = flash_message_for(@object, :successfully_updated) + redirect_to location_after_save + end + format.js { render layout: false } + end + else + invoke_callbacks(:update, :fails) + respond_with(@object) do |format| + format.html { render action: :edit } + format.js { render layout: false } + end + end + end + + def create + invoke_callbacks(:create, :before) + @object.attributes = permitted_resource_params + if @object.save + invoke_callbacks(:create, :after) + flash[:success] = flash_message_for(@object, :successfully_created) + respond_with(@object) do |format| + format.html { redirect_to location_after_save } + format.js { render layout: false } + end + else + invoke_callbacks(:create, :fails) + respond_with(@object) do |format| + format.html { render action: :new } + format.js { render layout: false } + end + end + end + + def update_positions + ApplicationRecord.transaction do + params[:positions].each do |id, index| + model_class.find(id).set_list_position(index) + end + end + + respond_to do |format| + format.js { render plain: 'Ok' } + end + end + + def destroy + invoke_callbacks(:destroy, :before) + if @object.destroy + invoke_callbacks(:destroy, :after) + flash[:success] = flash_message_for(@object, :successfully_removed) + else + invoke_callbacks(:destroy, :fails) + flash[:error] = @object.errors.full_messages.join(', ') + end + + respond_with(@object) do |format| + format.html { redirect_to location_after_destroy } + format.js { render_js_for_destroy } + end + end + + protected + + class << self + attr_accessor :parent_data + + def belongs_to(model_name, options = {}) + @parent_data ||= {} + @parent_data[:model_name] = model_name + @parent_data[:model_class] = model_name.to_s.classify.constantize + @parent_data[:find_by] = options[:find_by] || :id + end + end + + def model_class + @model_class ||= resource.model_class + end + + def resource_not_found + flash[:error] = flash_message_for(model_class.new, :not_found) + redirect_to collection_url + end + + def resource + return @resource if @resource + + parent_model_name = parent_data[:model_name] if parent_data + @resource = Spree::Admin::Resource.new controller_path, controller_name, parent_model_name, object_name + end + + def load_resource + if member_action? + @object ||= load_resource_instance + + # call authorize! a third time (called twice already in Admin::BaseController) + # this time we pass the actual instance so fine-grained abilities can control + # access to individual records, not just entire models. + authorize! action, @object + + instance_variable_set("@#{resource.object_name}", @object) + else + @collection ||= collection + + # note: we don't call authorize here as the collection method should use + # CanCan's accessible_by method to restrict the actual records returned + + instance_variable_set("@#{controller_name}", @collection) + end + end + + def load_resource_instance + if new_actions.include?(action) + build_resource + elsif params[:id] + find_resource + end + end + + def parent_data + self.class.parent_data + end + + def parent + if parent_data.present? + @parent ||= parent_data[:model_class]. + # Don't use `find_by_attribute_name` to workaround globalize/globalize#423 bug + send(:find_by, parent_data[:find_by].to_s => params["#{resource.model_name}_id"]) + instance_variable_set("@#{resource.model_name}", @parent) + end + end + + def find_resource + if parent_data.present? + parent.send(controller_name).find(params[:id]) + else + model_class.find(params[:id]) + end + end + + def build_resource + if parent_data.present? + parent.send(controller_name).build + else + model_class.new + end + end + + def collection + return parent.send(controller_name) if parent_data.present? + + if model_class.respond_to?(:accessible_by) && + !current_ability.has_block?(params[:action], model_class) + model_class.accessible_by(current_ability, action) + else + model_class.where(nil) + end + end + + def location_after_destroy + collection_url + end + + def location_after_save + collection_url + end + + # URL helpers + + def new_object_url(options = {}) + if parent_data.present? + spree.new_polymorphic_url([:admin, parent, model_class], options) + else + spree.new_polymorphic_url([:admin, model_class], options) + end + end + + def edit_object_url(object, options = {}) + if parent_data.present? + spree.send "edit_admin_#{resource.model_name}_#{resource.object_name}_url", + parent, object, options + else + spree.send "edit_admin_#{resource.object_name}_url", object, options + end + end + + def object_url(object = nil, options = {}) + target = object || @object + if parent_data.present? + spree.send "admin_#{resource.model_name}_#{resource.object_name}_url", parent, target, options + else + spree.send "admin_#{resource.object_name}_url", target, options + end + end + + def collection_url(options = {}) + if parent_data.present? + spree.polymorphic_url([:admin, parent, model_class], options) + else + spree.polymorphic_url([:admin, model_class], options) + end + end + + # This method should be overridden when object_name does not match the controller name + def object_name; end + + # Allow all attributes to be updatable. + # + # Other controllers can, should, override it to set custom logic + def permitted_resource_params + params[resource.object_name].present? ? params.require(resource.object_name).permit! : ActionController::Parameters.new + end + + def collection_actions + [:index] + end + + def member_action? + !collection_actions.include? action + end + + def new_actions + [:new, :create] + end +end diff --git a/backend/app/controllers/spree/admin/return_authorization_reasons_controller.rb b/backend/app/controllers/spree/admin/return_authorization_reasons_controller.rb new file mode 100644 index 00000000000..fd7a7b135d6 --- /dev/null +++ b/backend/app/controllers/spree/admin/return_authorization_reasons_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class ReturnAuthorizationReasonsController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/return_authorizations_controller.rb b/backend/app/controllers/spree/admin/return_authorizations_controller.rb new file mode 100644 index 00000000000..fdf6513c81a --- /dev/null +++ b/backend/app/controllers/spree/admin/return_authorizations_controller.rb @@ -0,0 +1,51 @@ +module Spree + module Admin + class ReturnAuthorizationsController < ResourceController + belongs_to 'spree/order', find_by: :number + + before_action :load_form_data, only: [:new, :edit] + create.fails :load_form_data + update.fails :load_form_data + + def fire + @return_authorization.send("#{params[:e]}!") + flash[:success] = Spree.t(:return_authorization_updated) + redirect_back fallback_location: spree.edit_admin_order_return_authorization_path(@order, @return_authorization) + end + + private + + def load_form_data + load_return_items + load_reimbursement_types + load_return_authorization_reasons + end + + # To satisfy how nested attributes works we want to create placeholder ReturnItems for + # any InventoryUnits that have not already been added to the ReturnAuthorization. + def load_return_items + all_inventory_units = @return_authorization.order.inventory_units + associated_inventory_units = @return_authorization.return_items.map(&:inventory_unit) + unassociated_inventory_units = all_inventory_units - associated_inventory_units + + new_return_items = unassociated_inventory_units.map do |new_unit| + Spree::ReturnItem.new(inventory_unit: new_unit, return_authorization: @return_authorization).tap(&:set_default_pre_tax_amount) + end + + @form_return_items = (@return_authorization.return_items + new_return_items).sort_by(&:inventory_unit_id) + end + + def load_reimbursement_types + @reimbursement_types = Spree::ReimbursementType.accessible_by(current_ability, :read).active + end + + def load_return_authorization_reasons + @reasons = Spree::ReturnAuthorizationReason.active.to_a + # Only allow an inactive reason if it's already associated to the RMA + if @return_authorization.reason && !@return_authorization.reason.active? + @reasons << @return_authorization.reason + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/return_index_controller.rb b/backend/app/controllers/spree/admin/return_index_controller.rb new file mode 100644 index 00000000000..23c17b4a24f --- /dev/null +++ b/backend/app/controllers/spree/admin/return_index_controller.rb @@ -0,0 +1,29 @@ +module Spree + module Admin + class ReturnIndexController < BaseController + def return_authorizations + collection(Spree::ReturnAuthorization) + respond_with(@collection) + end + + def customer_returns + collection(Spree::CustomerReturn) + respond_with(@collection) + end + + private + + def collection(resource) + return @collection if @collection.present? + + params[:q] ||= {} + + @collection = resource.all + # @search needs to be defined as this is passed to search_form_for + @search = @collection.ransack(params[:q]) + per_page = params[:per_page] || Spree::Config[:admin_customer_returns_per_page] + @collection = @search.result.order(created_at: :desc).page(params[:page]).per(per_page) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/return_items_controller.rb b/backend/app/controllers/spree/admin/return_items_controller.rb new file mode 100644 index 00000000000..9e1f3b941c3 --- /dev/null +++ b/backend/app/controllers/spree/admin/return_items_controller.rb @@ -0,0 +1,9 @@ +module Spree + module Admin + class ReturnItemsController < ResourceController + def location_after_save + url_for([:edit, :admin, @return_item.customer_return.order, @return_item.customer_return]) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/roles_controller.rb b/backend/app/controllers/spree/admin/roles_controller.rb new file mode 100644 index 00000000000..4adbce634fd --- /dev/null +++ b/backend/app/controllers/spree/admin/roles_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class RolesController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/shipping_categories_controller.rb b/backend/app/controllers/spree/admin/shipping_categories_controller.rb new file mode 100644 index 00000000000..e9d16770274 --- /dev/null +++ b/backend/app/controllers/spree/admin/shipping_categories_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class ShippingCategoriesController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/shipping_methods_controller.rb b/backend/app/controllers/spree/admin/shipping_methods_controller.rb new file mode 100644 index 00000000000..d381036fe28 --- /dev/null +++ b/backend/app/controllers/spree/admin/shipping_methods_controller.rb @@ -0,0 +1,48 @@ +module Spree + module Admin + class ShippingMethodsController < ResourceController + before_action :load_data, except: :index + before_action :set_shipping_category, only: [:create, :update] + before_action :set_zones, only: [:create, :update] + + def destroy + @object.destroy + + flash[:success] = flash_message_for(@object, :successfully_removed) + + respond_with(@object) do |format| + format.html { redirect_to collection_url } + format.js { render_js_for_destroy } + end + end + + private + + def set_shipping_category + return true if params['shipping_method'][:shipping_categories].blank? + + @shipping_method.shipping_categories = Spree::ShippingCategory.where(id: params['shipping_method'][:shipping_categories]) + @shipping_method.save + params[:shipping_method].delete(:shipping_categories) + end + + def set_zones + return true if params['shipping_method'][:zones].blank? + + @shipping_method.zones = Spree::Zone.where(id: params['shipping_method'][:zones]) + @shipping_method.save + params[:shipping_method].delete(:zones) + end + + def location_after_save + edit_admin_shipping_method_path(@shipping_method) + end + + def load_data + @available_zones = Zone.order(:name) + @tax_categories = Spree::TaxCategory.order(:name) + @calculators = ShippingMethod.calculators.sort_by(&:name) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/state_changes_controller.rb b/backend/app/controllers/spree/admin/state_changes_controller.rb new file mode 100644 index 00000000000..f5a0ce8dcbf --- /dev/null +++ b/backend/app/controllers/spree/admin/state_changes_controller.rb @@ -0,0 +1,18 @@ +module Spree + module Admin + class StateChangesController < Spree::Admin::BaseController + before_action :load_order, only: [:index] + + def index + @state_changes = @order.state_changes.includes(:user).order(created_at: :desc) + end + + private + + def load_order + @order = Order.find_by!(number: params[:order_id]) + authorize! action, @order + end + end + end +end diff --git a/backend/app/controllers/spree/admin/states_controller.rb b/backend/app/controllers/spree/admin/states_controller.rb new file mode 100644 index 00000000000..9e0d376774f --- /dev/null +++ b/backend/app/controllers/spree/admin/states_controller.rb @@ -0,0 +1,29 @@ +module Spree + module Admin + class StatesController < ResourceController + belongs_to 'spree/country' + before_action :load_data + + def index + respond_with(@collection) do |format| + format.html + format.js { render partial: 'state_list' } + end + end + + protected + + def location_after_save + admin_country_states_url(@country) + end + + def collection + super.order(:name) + end + + def load_data + @countries = Country.order(:name) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/stock_items_controller.rb b/backend/app/controllers/spree/admin/stock_items_controller.rb new file mode 100644 index 00000000000..1f127b67030 --- /dev/null +++ b/backend/app/controllers/spree/admin/stock_items_controller.rb @@ -0,0 +1,52 @@ +module Spree + module Admin + class StockItemsController < Spree::Admin::BaseController + before_action :determine_backorderable, only: :update + + def update + stock_item.save + respond_to do |format| + format.js { head :ok } + end + end + + def create + variant = Variant.find(params[:variant_id]) + stock_location = StockLocation.find(params[:stock_location_id]) + stock_movement = stock_location.stock_movements.build(stock_movement_params) + stock_movement.stock_item = stock_location.set_up_stock_item(variant) + + if stock_movement.save + flash[:success] = flash_message_for(stock_movement, :successfully_created) + else + flash[:error] = Spree.t(:could_not_create_stock_movement) + end + + redirect_back fallback_location: spree.stock_admin_product_url(variant.product) + end + + def destroy + stock_item.destroy + + respond_with(@stock_item) do |format| + format.html { redirect_back fallback_location: spree.stock_admin_product_url(stock_item.product) } + format.js + end + end + + private + + def stock_movement_params + params.require(:stock_movement).permit(permitted_stock_movement_attributes) + end + + def stock_item + @stock_item ||= StockItem.find(params[:id]) + end + + def determine_backorderable + stock_item.backorderable = params[:stock_item].present? && params[:stock_item][:backorderable].present? + end + end + end +end diff --git a/backend/app/controllers/spree/admin/stock_locations_controller.rb b/backend/app/controllers/spree/admin/stock_locations_controller.rb new file mode 100644 index 00000000000..9eb10be76af --- /dev/null +++ b/backend/app/controllers/spree/admin/stock_locations_controller.rb @@ -0,0 +1,17 @@ +module Spree + module Admin + class StockLocationsController < ResourceController + before_action :set_country, only: :new + + private + + def set_country + @stock_location.country = Spree::Country.default + unless @stock_location.country + flash[:error] = Spree.t(:stock_locations_need_a_default_country) + redirect_to admin_stock_locations_path + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/stock_movements_controller.rb b/backend/app/controllers/spree/admin/stock_movements_controller.rb new file mode 100644 index 00000000000..50f7dbf5ff1 --- /dev/null +++ b/backend/app/controllers/spree/admin/stock_movements_controller.rb @@ -0,0 +1,38 @@ +module Spree + module Admin + class StockMovementsController < Spree::Admin::BaseController + respond_to :html + helper_method :stock_location + + def index + @stock_movements = stock_location.stock_movements.recent. + includes(stock_item: { variant: :product }). + page(params[:page]) + end + + def new + @stock_movement = stock_location.stock_movements.build + end + + def create + @stock_movement = stock_location.stock_movements.build(stock_movement_params) + if @stock_movement.save + flash[:success] = flash_message_for(@stock_movement, :successfully_created) + redirect_to admin_stock_location_stock_movements_path(stock_location) + else + render :new + end + end + + private + + def stock_location + @stock_location ||= StockLocation.find(params[:stock_location_id]) + end + + def stock_movement_params + params.require(:stock_movement).permit(:quantity, :stock_item_id, :action) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/stock_transfers_controller.rb b/backend/app/controllers/spree/admin/stock_transfers_controller.rb new file mode 100644 index 00000000000..bb5181e793d --- /dev/null +++ b/backend/app/controllers/spree/admin/stock_transfers_controller.rb @@ -0,0 +1,56 @@ +module Spree + module Admin + class StockTransfersController < Admin::BaseController + before_action :load_stock_locations, only: :index + + def index + @q = StockTransfer.ransack(params[:q]) + + @stock_transfers = @q.result. + includes(stock_movements: { stock_item: :stock_location }). + order(created_at: :desc). + page(params[:page]) + end + + def show + @stock_transfer = StockTransfer.find_by!(number: params[:id]) + end + + def new; end + + def create + if params[:variant].nil? + flash[:error] = Spree.t('stock_transfer.errors.must_have_variant') + render :new + else + variants = Hash.new(0) + params[:variant].each_with_index do |variant_id, i| + variants[variant_id] += params[:quantity][i].to_i + end + stock_transfer = StockTransfer.create(reference: params[:reference]) + stock_transfer.transfer(source_location, + destination_location, + variants) + + flash[:success] = Spree.t(:stock_successfully_transferred) + redirect_to admin_stock_transfer_path(stock_transfer) + end + end + + private + + def load_stock_locations + @stock_locations = Spree::StockLocation.active.order_default + end + + def source_location + @source_location ||= params.key?(:transfer_receive_stock) ? nil : + StockLocation.find(params[:transfer_source_location_id]) + end + + def destination_location + @destination_location ||= StockLocation.find(params[:transfer_destination_location_id]) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/store_credit_categories_controller.rb b/backend/app/controllers/spree/admin/store_credit_categories_controller.rb new file mode 100644 index 00000000000..cb40f19e629 --- /dev/null +++ b/backend/app/controllers/spree/admin/store_credit_categories_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class StoreCreditCategoriesController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/store_credits_controller.rb b/backend/app/controllers/spree/admin/store_credits_controller.rb new file mode 100644 index 00000000000..0910f9feacf --- /dev/null +++ b/backend/app/controllers/spree/admin/store_credits_controller.rb @@ -0,0 +1,94 @@ +module Spree + module Admin + class StoreCreditError < StandardError; end + + class StoreCreditsController < Spree::Admin::BaseController + before_action :load_user + before_action :load_categories, only: [:new, :edit] + before_action :load_store_credit, only: [:new, :edit, :update] + before_action :ensure_unused_store_credit, only: [:update] + + def index + @store_credits = @user.store_credits.includes(:credit_type, :category).reverse_order + end + + def create + @store_credit = @user.store_credits.build( + permitted_store_credit_params.merge( + created_by: try_spree_current_user, + action_originator: try_spree_current_user + ) + ) + + if @store_credit.save + flash[:success] = flash_message_for(@store_credit, :successfully_created) + redirect_to admin_user_store_credits_path(@user) + else + load_categories + flash[:error] = Spree.t('store_credit.errors.unable_to_create') + render :new + end + end + + def update + @store_credit.assign_attributes(permitted_store_credit_params) + @store_credit.created_by = try_spree_current_user + + if @store_credit.save + flash[:success] = flash_message_for(@store_credit, :successfully_updated) + redirect_to admin_user_store_credits_path(@user) + else + load_categories + flash[:error] = Spree.t('store_credit.errors.unable_to_update') + render :edit + end + end + + def destroy + @store_credit = @user.store_credits.find(params[:id]) + ensure_unused_store_credit + + if @store_credit.destroy + flash[:success] = flash_message_for(@store_credit, :successfully_removed) + respond_with(@store_credit) do |format| + format.html { redirect_to admin_user_store_credits_path(@user) } + format.js { render_js_for_destroy } + end + else + render plain: Spree.t('store_credit.errors.unable_to_delete'), status: :unprocessable_entity + end + end + + protected + + def permitted_store_credit_params + params.require(:store_credit).permit(permitted_store_credit_attributes) + end + + private + + def load_user + @user = Spree.user_class.find_by(id: params[:user_id]) + + unless @user + flash[:error] = Spree.t(:user_not_found) + redirect_to admin_path + end + end + + def load_categories + @credit_categories = Spree::StoreCreditCategory.order(:name) + end + + def load_store_credit + @store_credit = Spree::StoreCredit.find_by(id: params[:id]) || Spree::StoreCredit.new + end + + def ensure_unused_store_credit + unless @store_credit.amount_used.zero? + raise StoreCreditError, Spree.t('store_credit.errors.cannot_change_used_store_credit') + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/stores_controller.rb b/backend/app/controllers/spree/admin/stores_controller.rb new file mode 100644 index 00000000000..d0f6203b011 --- /dev/null +++ b/backend/app/controllers/spree/admin/stores_controller.rb @@ -0,0 +1,79 @@ +module Spree + module Admin + class StoresController < Spree::Admin::BaseController + before_action :load_store, only: [:new, :edit, :update] + + def index + @stores = Spree::Store.all + end + + def create + @store = Spree::Store.new(permitted_store_params) + + if @store.save + flash[:success] = flash_message_for(@store, :successfully_created) + redirect_to admin_stores_path + else + flash[:error] = "#{Spree.t('store.errors.unable_to_create')}: #{@store.errors.full_messages.join(', ')}" + render :new + end + end + + def update + @store.assign_attributes(permitted_store_params) + + if @store.save + flash[:success] = flash_message_for(@store, :successfully_updated) + redirect_to admin_stores_path + else + flash[:error] = "#{Spree.t('store.errors.unable_to_update')}: #{@store.errors.full_messages.join(', ')}" + render :edit + end + end + + def destroy + @store = Spree::Store.find(params[:id]) + + if @store.destroy + flash[:success] = flash_message_for(@store, :successfully_removed) + respond_with(@store) do |format| + format.html { redirect_to admin_stores_path } + format.js { render_js_for_destroy } + end + else + render plain: "#{Spree.t('store.errors.unable_to_delete')}: #{@store.errors.full_messages.join(', ')}", status: :unprocessable_entity + end + end + + def set_default + store = Spree::Store.find(params[:id]) + stores = Spree::Store.where.not(id: params[:id]) + + ApplicationRecord.transaction do + store.update(default: true) + stores.update_all(default: false) + end + + if store.errors.empty? + flash[:success] = Spree.t(:store_set_as_default, store: store.name) + else + flash[:error] = "#{Spree.t(:store_not_set_as_default, store: store.name)} #{store.errors.full_messages.join(', ')}" + end + + redirect_to admin_stores_path + end + + protected + + def permitted_store_params + params.require(:store).permit(permitted_store_attributes) + end + + private + + def load_store + @store = Spree::Store.find_by(id: params[:id]) || Spree::Store.new + end + end + end +end diff --git a/backend/app/controllers/spree/admin/tax_categories_controller.rb b/backend/app/controllers/spree/admin/tax_categories_controller.rb new file mode 100644 index 00000000000..34a050d5864 --- /dev/null +++ b/backend/app/controllers/spree/admin/tax_categories_controller.rb @@ -0,0 +1,6 @@ +module Spree + module Admin + class TaxCategoriesController < ResourceController + end + end +end diff --git a/backend/app/controllers/spree/admin/tax_rates_controller.rb b/backend/app/controllers/spree/admin/tax_rates_controller.rb new file mode 100644 index 00000000000..15db5a1493b --- /dev/null +++ b/backend/app/controllers/spree/admin/tax_rates_controller.rb @@ -0,0 +1,15 @@ +module Spree + module Admin + class TaxRatesController < ResourceController + before_action :load_data + + private + + def load_data + @available_zones = Zone.order(:name) + @available_categories = TaxCategory.order(:name) + @calculators = TaxRate.calculators.sort_by(&:name) + end + end + end +end diff --git a/backend/app/controllers/spree/admin/taxonomies_controller.rb b/backend/app/controllers/spree/admin/taxonomies_controller.rb new file mode 100644 index 00000000000..6963dedde89 --- /dev/null +++ b/backend/app/controllers/spree/admin/taxonomies_controller.rb @@ -0,0 +1,15 @@ +module Spree + module Admin + class TaxonomiesController < ResourceController + private + + def location_after_save + if @taxonomy.created_at == @taxonomy.updated_at + edit_admin_taxonomy_url(@taxonomy) + else + admin_taxonomies_url + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/taxons_controller.rb b/backend/app/controllers/spree/admin/taxons_controller.rb new file mode 100644 index 00000000000..68578570ac6 --- /dev/null +++ b/backend/app/controllers/spree/admin/taxons_controller.rb @@ -0,0 +1,118 @@ +module Spree + module Admin + class TaxonsController < Spree::Admin::BaseController + before_action :load_taxonomy, only: [:create, :edit, :update] + before_action :load_taxon, only: [:edit, :update] + before_action :set_permalink_part, only: [:edit, :update] + respond_to :html, :js + + def index; end + + def create + @taxon = @taxonomy.taxons.build(params[:taxon].except(:icon)) + @taxon.build_icon(attachment: taxon_params[:icon]) + if @taxon.save + respond_with(@taxon) do |format| + format.json { render json: @taxon.to_json } + end + else + flash[:error] = Spree.t('errors.messages.could_not_create_taxon') + respond_with(@taxon) do |format| + format.html { redirect_to @taxonomy ? edit_admin_taxonomy_url(@taxonomy) : admin_taxonomies_url } + end + end + end + + def edit; end + + def update + successful = @taxon.transaction do + parent_id = params[:taxon][:parent_id] + set_position + set_parent(parent_id) + + @taxon.save! + + # regenerate permalink + regenerate_permalink if parent_id + + set_permalink_params + + # check if we need to rename child taxons if parent name or permalink changes + @update_children = true if params[:taxon][:name] != @taxon.name || params[:taxon][:permalink] != @taxon.permalink + + @taxon.create_icon(attachment: taxon_params[:icon]) if taxon_params[:icon] + @taxon.update_attributes(taxon_params.except(:icon)) + end + if successful + flash[:success] = flash_message_for(@taxon, :successfully_updated) + + # rename child taxons + rename_child_taxons if @update_children + + respond_with(@taxon) do |format| + format.html { redirect_to edit_admin_taxonomy_url(@taxonomy) } + format.json { render json: @taxon.to_json } + end + else + respond_with(@taxon) do |format| + format.html { render :edit } + format.json { render json: @taxon.errors.full_messages.to_sentence, status: 422 } + end + end + end + + private + + def set_permalink_part + @permalink_part = @taxon.permalink.split('/').last + end + + def taxon_params + params.require(:taxon).permit(permitted_taxon_attributes) + end + + def load_taxon + @taxon = @taxonomy.taxons.find(params[:id]) + end + + def load_taxonomy + @taxonomy = Taxonomy.find(params[:taxonomy_id]) + end + + def set_position + new_position = params[:taxon][:position] + @taxon.child_index = new_position.to_i if new_position + end + + def set_parent(parent_id) + @taxon.parent = Taxon.find(parent_id.to_i) if parent_id + end + + def set_permalink_params + if params.key? 'permalink_part' + parent_permalink = @taxon.permalink.split('/')[0...-1].join('/') + parent_permalink += '/' unless parent_permalink.blank? + params[:taxon][:permalink] = parent_permalink + params[:permalink_part] + end + end + + def rename_child_taxons + @taxon.descendants.each do |taxon| + reload_taxon_and_set_permalink(taxon) + end + end + + def regenerate_permalink + reload_taxon_and_set_permalink(@taxon) + @update_children = true + end + + def reload_taxon_and_set_permalink(taxon) + taxon.reload + taxon.set_permalink + taxon.save! + end + end + end +end diff --git a/backend/app/controllers/spree/admin/users_controller.rb b/backend/app/controllers/spree/admin/users_controller.rb new file mode 100644 index 00000000000..4cbaa743855 --- /dev/null +++ b/backend/app/controllers/spree/admin/users_controller.rb @@ -0,0 +1,113 @@ +module Spree + module Admin + class UsersController < ResourceController + rescue_from Spree::Core::DestroyWithOrdersError, with: :user_destroy_with_orders_error + + after_action :sign_in_if_change_own_password, only: :update + + def show + redirect_to edit_admin_user_path(@user) + end + + def create + @user = Spree.user_class.new(user_params) + if @user.save + flash[:success] = flash_message_for(@user, :successfully_created) + redirect_to edit_admin_user_path(@user) + else + render :new + end + end + + def update + if params[:user][:password].blank? && params[:user][:password_confirmation].blank? + params[:user].delete(:password) + params[:user].delete(:password_confirmation) + end + + if @user.update_attributes(user_params) + flash[:success] = Spree.t(:account_updated) + redirect_to edit_admin_user_path(@user) + else + render :edit + end + end + + def addresses + if request.put? + if @user.update_attributes(user_params) + flash.now[:success] = Spree.t(:account_updated) + end + + render :addresses + end + end + + def orders + params[:q] ||= {} + @search = Spree::Order.reverse_chronological.ransack(params[:q].merge(user_id_eq: @user.id)) + @orders = @search.result.page(params[:page]) + end + + def items + params[:q] ||= {} + @search = Spree::Order.includes( + line_items: { + variant: [:product, { option_values: :option_type }] + } + ).ransack(params[:q].merge(user_id_eq: @user.id)) + @orders = @search.result.page(params[:page]) + end + + def generate_api_key + if @user.generate_spree_api_key! + flash[:success] = Spree.t('api.key_generated') + end + redirect_to edit_admin_user_path(@user) + end + + def clear_api_key + if @user.clear_spree_api_key! + flash[:success] = Spree.t('api.key_cleared') + end + redirect_to edit_admin_user_path(@user) + end + + def model_class + Spree.user_class + end + + protected + + def collection + return @collection if @collection.present? + + @collection = super + @search = @collection.ransack(params[:q]) + @collection = @search.result.page(params[:page]).per(Spree::Config[:admin_users_per_page]) + end + + private + + def user_params + params.require(:user).permit(permitted_user_attributes | + [:use_billing, + spree_role_ids: [], + ship_address_attributes: permitted_address_attributes, + bill_address_attributes: permitted_address_attributes]) + end + + # handling raise from Spree::Admin::ResourceController#destroy + def user_destroy_with_orders_error + invoke_callbacks(:destroy, :fails) + render status: :forbidden, plain: Spree.t(:error_user_destroy_with_orders) + end + + def sign_in_if_change_own_password + if try_spree_current_user == @user && @user.password.present? + sign_in(@user, event: :authentication, bypass: true) + end + end + end + end +end diff --git a/backend/app/controllers/spree/admin/variants_controller.rb b/backend/app/controllers/spree/admin/variants_controller.rb new file mode 100644 index 00000000000..823240f8482 --- /dev/null +++ b/backend/app/controllers/spree/admin/variants_controller.rb @@ -0,0 +1,64 @@ +module Spree + module Admin + class VariantsController < ResourceController + belongs_to 'spree/product', find_by: :slug + new_action.before :new_before + before_action :redirect_on_empty_option_values, only: [:new] + before_action :load_data, only: [:new, :create, :edit, :update] + + # override the destroy method to set deleted_at value + # instead of actually deleting the product. + def destroy + @variant = Variant.find(params[:id]) + if @variant.destroy + flash[:success] = Spree.t('notice_messages.variant_deleted') + else + flash[:error] = Spree.t('notice_messages.variant_not_deleted', error: @variant.errors.full_messages.to_sentence) + end + + respond_with(@variant) do |format| + format.html { redirect_to admin_product_variants_url(params[:product_id]) } + format.js { render_js_for_destroy } + end + end + + protected + + def new_before + master = @object.product.master + @object.attributes = master.attributes.except( + 'id', 'created_at', 'deleted_at', 'sku', 'is_master' + ) + + # Shallow Clone of the default price to populate the price field. + @object.default_price = master.default_price.clone if master.default_price.present? + end + + def parent + @product = Product.with_deleted.friendly.find(params[:product_id]) + end + + def collection + @deleted = params.key?(:deleted) && params[:deleted] == 'on' ? 'checked' : '' + + @collection ||= + if @deleted.blank? + super.includes(:default_price, option_values: :option_type) + else + Variant.only_deleted.where(product_id: parent.id) + end + @collection + end + + private + + def load_data + @tax_categories = TaxCategory.order(:name) + end + + def redirect_on_empty_option_values + redirect_to admin_product_variants_url(params[:product_id]) if @product.empty_option_values? + end + end + end +end diff --git a/backend/app/controllers/spree/admin/variants_including_master_controller.rb b/backend/app/controllers/spree/admin/variants_including_master_controller.rb new file mode 100644 index 00000000000..ac102ca644b --- /dev/null +++ b/backend/app/controllers/spree/admin/variants_including_master_controller.rb @@ -0,0 +1,15 @@ +module Spree + module Admin + class VariantsIncludingMasterController < VariantsController + belongs_to 'spree/product', find_by: :slug + + def model_class + Spree::Variant + end + + def object_name + 'variant' + end + end + end +end diff --git a/backend/app/controllers/spree/admin/zones_controller.rb b/backend/app/controllers/spree/admin/zones_controller.rb new file mode 100644 index 00000000000..62f7be47e65 --- /dev/null +++ b/backend/app/controllers/spree/admin/zones_controller.rb @@ -0,0 +1,26 @@ +module Spree + module Admin + class ZonesController < ResourceController + before_action :load_data, except: :index + + def new + @zone.zone_members.build + end + + protected + + def collection + params[:q] ||= {} + params[:q][:s] ||= 'name asc' + @search = super.ransack(params[:q]) + @zones = @search.result.page(params[:page]).per(params[:per_page]) + end + + def load_data + @countries = Country.order(:name) + @states = State.order(:name) + @zones = Zone.order(:name) + end + end + end +end diff --git a/backend/app/helpers/spree/admin/adjustments_helper.rb b/backend/app/helpers/spree/admin/adjustments_helper.rb new file mode 100644 index 00000000000..847c39a9754 --- /dev/null +++ b/backend/app/helpers/spree/admin/adjustments_helper.rb @@ -0,0 +1,35 @@ +module Spree + module Admin + module AdjustmentsHelper + def display_adjustable(adjustable) + case adjustable + when Spree::LineItem + display_line_item(adjustable) + when Spree::Shipment + display_shipment(adjustable) + when Spree::Order + display_order(adjustable) + end + end + + private + + def display_line_item(line_item) + variant = line_item.variant + parts = [] + parts << variant.product.name + parts << "(#{variant.options_text})" if variant.options_text.present? + parts << line_item.display_total + safe_join(parts, '
    '.html_safe) + end + + def display_shipment(shipment) + "#{Spree.t(:shipment)} ##{shipment.number}
    #{shipment.display_cost}".html_safe + end + + def display_order(_order) + Spree.t(:order) + end + end + end +end diff --git a/backend/app/helpers/spree/admin/base_helper.rb b/backend/app/helpers/spree/admin/base_helper.rb new file mode 100644 index 00000000000..577ea087a68 --- /dev/null +++ b/backend/app/helpers/spree/admin/base_helper.rb @@ -0,0 +1,152 @@ +module Spree + module Admin + module BaseHelper + def flash_alert(flash) + if flash.present? + close_button = button_tag(class: 'close', 'data-dismiss' => 'alert', 'aria-label' => Spree.t(:close)) do + content_tag('span', '×'.html_safe, 'aria-hidden' => true) + end + message = flash[:error] || flash[:notice] || flash[:success] + flash_class = 'danger' if flash[:error] + flash_class = 'info' if flash[:notice] + flash_class = 'success' if flash[:success] + flash_div = content_tag(:div, (close_button + message), class: "alert alert-#{flash_class} alert-auto-disappear") + content_tag(:div, flash_div, class: 'col-xs-12') + end + end + + def field_container(model, method, options = {}, &block) + css_classes = options[:class].to_a + css_classes << 'field' + css_classes << 'withError' if error_message_on(model, method).present? + content_tag( + :div, capture(&block), + options.merge(class: css_classes.join(' '), id: "#{model}_#{method}_field") + ) + end + + def error_message_on(object, method, _options = {}) + object = convert_to_model(object) + obj = object.respond_to?(:errors) ? object : instance_variable_get("@#{object}") + + if obj && obj.errors[method].present? + errors = safe_join(obj.errors[method], '
    '.html_safe) + content_tag(:span, errors, class: 'formError') + else + '' + end + end + + def datepicker_field_value(date) + unless date.blank? + l(date, format: Spree.t('date_picker.format', default: '%Y/%m/%d')) + end + end + + def preference_field_tag(name, value, options) + case options[:type] + when :integer + text_field_tag(name, value, preference_field_options(options)) + when :boolean + hidden_field_tag(name, 0, id: "#{name}_hidden") + + check_box_tag(name, 1, value, preference_field_options(options)) + when :string + text_field_tag(name, value, preference_field_options(options)) + when :password + password_field_tag(name, value, preference_field_options(options)) + when :text + text_area_tag(name, value, preference_field_options(options)) + else + text_field_tag(name, value, preference_field_options(options)) + end + end + + def preference_field_for(form, field, options) + case options[:type] + when :integer + form.text_field(field, preference_field_options(options)) + when :boolean + form.check_box(field, preference_field_options(options)) + when :string + form.text_field(field, preference_field_options(options)) + when :password + form.password_field(field, preference_field_options(options)) + when :text + form.text_area(field, preference_field_options(options)) + else + form.text_field(field, preference_field_options(options)) + end + end + + def preference_field_options(options) + field_options = case options[:type] + when :integer + { + size: 10, + class: 'input_integer form-control' + } + when :boolean + {} + when :string + { + size: 10, + class: 'input_string form-control' + } + when :password + { + size: 10, + class: 'password_string form-control' + } + when :text + { + rows: 15, + cols: 85, + class: 'form-control' + } + else + { + size: 10, + class: 'input_string form-control' + } + end + + field_options.merge!(readonly: options[:readonly], + disabled: options[:disabled], + size: options[:size]) + end + + def preference_fields(object, form) + return unless object.respond_to?(:preferences) + + fields = object.preferences.keys.map do |key| + if object.has_preference?(key) + form.label("preferred_#{key}", Spree.t(key) + ': ') + + preference_field_for(form, "preferred_#{key}", type: object.preference_type(key)) + end + end + safe_join(fields, '
    '.html_safe) + end + + # renders hidden field and link to remove record using nested_attributes + def link_to_icon_remove_fields(form) + url = form.object.persisted? ? [:admin, form.object] : '#' + css_class = 'spree_remove_fields btn btn-sm btn-danger' + title = Spree.t(:remove) + link_to_with_icon('delete', '', url, class: css_class, data: { action: 'remove' }, title: title) + form.hidden_field(:_destroy) + end + + def spree_dom_id(record) + dom_id(record, 'spree') + end + + I18N_PLURAL_MANY_COUNT = 2.1 + def plural_resource_name(resource_class) + resource_class.model_name.human(count: I18N_PLURAL_MANY_COUNT) + end + + def order_time(time) + [I18n.l(time.to_date), time.strftime('%l:%M %p').strip].join(' ') + end + end + end +end diff --git a/backend/app/helpers/spree/admin/currency_helper.rb b/backend/app/helpers/spree/admin/currency_helper.rb new file mode 100644 index 00000000000..4ba65edfa26 --- /dev/null +++ b/backend/app/helpers/spree/admin/currency_helper.rb @@ -0,0 +1,14 @@ +module Spree + module Admin + module CurrencyHelper + def currency_options(selected_value = nil) + selected_value ||= Spree::Config[:currency] + currencies = ::Money::Currency.table.map do |_code, details| + iso = details[:iso_code] + [iso, "#{details[:name]} (#{iso})"] + end + options_from_collection_for_select(currencies, :first, :last, selected_value) + end + end + end +end diff --git a/backend/app/helpers/spree/admin/customer_returns_helper.rb b/backend/app/helpers/spree/admin/customer_returns_helper.rb new file mode 100644 index 00000000000..8880f046e84 --- /dev/null +++ b/backend/app/helpers/spree/admin/customer_returns_helper.rb @@ -0,0 +1,9 @@ +module Spree + module Admin + module CustomerReturnsHelper + def reimbursement_types + @reimbursement_types ||= Spree::ReimbursementType.accessible_by(current_ability, :read).active + end + end + end +end diff --git a/backend/app/helpers/spree/admin/images_helper.rb b/backend/app/helpers/spree/admin/images_helper.rb new file mode 100644 index 00000000000..eed14efb912 --- /dev/null +++ b/backend/app/helpers/spree/admin/images_helper.rb @@ -0,0 +1,17 @@ +module Spree + module Admin + module ImagesHelper + def options_text_for(image) + if image.viewable.is_a?(Spree::Variant) + if image.viewable.is_master? + Spree.t(:all) + else + image.viewable.sku_and_options_text + end + else + Spree.t(:all) + end + end + end + end +end diff --git a/backend/app/helpers/spree/admin/navigation_helper.rb b/backend/app/helpers/spree/admin/navigation_helper.rb new file mode 100644 index 00000000000..88659b8adcc --- /dev/null +++ b/backend/app/helpers/spree/admin/navigation_helper.rb @@ -0,0 +1,234 @@ +module Spree + module Admin + module NavigationHelper + # Makes an admin navigation tab (
  • tag) that links to a routing resource under /admin. + # The arguments should be a list of symbolized controller names that will cause this tab to + # be highlighted, with the first being the name of the resouce to link (uses URL helpers). + # + # Option hash may follow. Valid options are + # * :label to override link text, otherwise based on the first resource name (translated) + # * :route to override automatically determining the default route + # * :match_path as an alternative way to control when the tab is active, /products would + # match /admin/products, /admin/products/5/variants etc. Can be a String or a Regexp. + # Controller names are ignored if :match_path is provided. + # + # Example: + # # Link to /admin/orders, also highlight tab for ProductsController and ShipmentsController + # tab :orders, :products, :shipments + def tab(*args) + options = { label: args.first.to_s } + + # Return if resource is found and user is not allowed to :admin + return '' if (klass = klass_for(options[:label])) && cannot?(:admin, klass) + + options = options.merge(args.pop) if args.last.is_a?(Hash) + options[:route] ||= "admin_#{args.first}" + + destination_url = options[:url] || spree.send("#{options[:route]}_path") + titleized_label = Spree.t(options[:label], default: options[:label], scope: [:admin, :tab]).titleize + + css_classes = ['sidebar-menu-item'] + + link = if options[:icon] + link_to_with_icon(options[:icon], titleized_label, destination_url) + else + link_to(titleized_label, destination_url) + end + + selected = if options[:match_path].is_a? Regexp + request.fullpath =~ options[:match_path] + elsif options[:match_path] + request.fullpath.starts_with?("#{spree.admin_path}#{options[:match_path]}") + else + args.include?(controller.controller_name.to_sym) + end + css_classes << 'selected' if selected + + css_classes << options[:css_class] if options[:css_class] + content_tag('li', link, class: css_classes.join(' ')) + end + + # Single main menu item + def main_menu_item(text, url: nil, icon: nil) + link_to url, 'data-toggle': 'collapse', 'data-parent': '#sidebar' do + content_tag(:span, nil, class: "icon icon-#{icon}") + + content_tag(:span, " #{text}", class: 'text') + + content_tag(:span, nil, class: 'icon icon-chevron-left pull-right') + end + end + + # Main menu tree menu + def main_menu_tree(text, icon: nil, sub_menu: nil, url: '#') + content_tag :li, class: 'sidebar-menu-item' do + main_menu_item(text, url: url, icon: icon) + + render(partial: "spree/admin/shared/sub_menu/#{sub_menu}") + end + end + + # the per_page_dropdown is used on index pages like orders, products, promotions etc. + # this method generates the select_tag + def per_page_dropdown + # there is a config setting for admin_products_per_page, only for the orders page + if @products && per_page_default = Spree::Config.admin_products_per_page + per_page_options = [] + 5.times do |amount| + per_page_options << (amount + 1) * Spree::Config.admin_products_per_page + end + else + per_page_default = Spree::Config.admin_orders_per_page + per_page_options = %w{15 30 45 60} + end + + selected_option = params[:per_page].try(:to_i) || per_page_default + + select_tag(:per_page, + options_for_select(per_page_options, selected_option), + class: "form-control pull-right js-per-page-select per-page-selected-#{selected_option}") + end + + # helper method to create proper url to apply per page filtering + # fixes https://github.com/spree/spree/issues/6888 + def per_page_dropdown_params(args = nil) + args = params.permit!.to_h.clone + args.delete(:page) + args.delete(:per_page) + args + end + + # finds class for a given symbol / string + # + # Example : + # :products returns Spree::Product + # :my_products returns MyProduct if MyProduct is defined + # :my_products returns My::Product if My::Product is defined + # if cannot constantize it returns nil + # This will allow us to use cancan abilities on tab + def klass_for(name) + model_name = name.to_s + + ["Spree::#{model_name.classify}", model_name.classify, model_name.tr('_', '/').classify].find(&:safe_constantize).try(:safe_constantize) + end + + def link_to_clone(resource, options = {}) + options[:data] = { action: 'clone', 'original-title': Spree.t(:clone) } + options[:class] = 'btn btn-primary btn-sm with-tip' + options[:method] = :post + options[:icon] = :clone + button_link_to '', clone_object_url(resource), options + end + + def link_to_clone_promotion(promotion, options = {}) + options[:data] = { action: 'clone', 'original-title': Spree.t(:clone) } + options[:class] = 'btn btn-warning btn-sm with-tip' + options[:method] = :post + options[:icon] = :clone + button_link_to '', clone_admin_promotion_path(promotion), options + end + + def link_to_edit(resource, options = {}) + url = options[:url] || edit_object_url(resource) + options[:data] = { action: 'edit' } + options[:class] = 'btn btn-primary btn-sm' + link_to_with_icon('edit', Spree.t(:edit), url, options) + end + + def link_to_edit_url(url, options = {}) + options[:data] = { action: 'edit' } + options[:class] = 'btn btn-primary btn-sm' + link_to_with_icon('edit', Spree.t(:edit), url, options) + end + + def link_to_delete(resource, options = {}) + url = options[:url] || object_url(resource) + name = options[:name] || Spree.t(:delete) + options[:class] = 'btn btn-danger btn-sm delete-resource' + options[:data] = { confirm: Spree.t(:are_you_sure), action: 'remove' } + link_to_with_icon 'delete', name, url, options + end + + def link_to_with_icon(icon_name, text, url, options = {}) + options[:class] = (options[:class].to_s + " icon-link with-tip action-#{icon_name}").strip + options[:class] += ' no-text' if options[:no_text] + options[:title] = text if options[:no_text] + text = options[:no_text] ? '' : content_tag(:span, text, class: 'text') + options.delete(:no_text) + if icon_name + icon = content_tag(:span, '', class: "icon icon-#{icon_name}") + text.insert(0, icon + ' ') + end + link_to(text.html_safe, url, options) + end + + def spree_icon(icon_name) + icon_name ? content_tag(:i, '', class: icon_name) : '' + end + + # Override: Add disable_with option to prevent multiple request on consecutive clicks + def button(text, icon_name = nil, button_type = 'submit', options = {}) + if icon_name + icon = content_tag(:span, '', class: "icon icon-#{icon_name}") + text.insert(0, icon + ' ') + end + button_tag(text.html_safe, options.merge(type: button_type, class: "btn btn-primary #{options[:class]}", 'data-disable-with' => "#{Spree.t(:saving)}...")) + end + + def button_link_to(text, url, html_options = {}) + if html_options[:method] && + !html_options[:method].to_s.casecmp('get').zero? && + !html_options[:remote] + form_tag(url, method: html_options.delete(:method), class: 'display-inline') do + button(text, html_options.delete(:icon), nil, html_options) + end + else + if html_options['data-update'].nil? && html_options[:remote] + object_name, action = url.split('/')[-2..-1] + html_options['data-update'] = [action, object_name.singularize].join('_') + end + + html_options.delete('data-update') unless html_options['data-update'] + + html_options[:class] = html_options[:class] ? "btn #{html_options[:class]}" : 'btn btn-default' + + if html_options[:icon] + icon = content_tag(:span, '', class: "icon icon-#{html_options[:icon]}") + text.insert(0, icon + ' ') + end + + link_to(text.html_safe, url, html_options) + end + end + + def configurations_sidebar_menu_item(link_text, url, options = {}) + is_selected = url.ends_with?(controller.controller_name) || + url.ends_with?("#{controller.controller_name}/edit") || + url.ends_with?("#{controller.controller_name.singularize}/edit") + + options[:class] = 'sidebar-menu-item' + options[:class] << ' selected' if is_selected + content_tag(:li, options) do + link_to(link_text, url) + end + end + + def main_part_classes + if cookies['sidebar-minimized'] == 'true' + 'col-xs-12 sidebar-collapsed' + else + 'col-xs-9 col-xs-offset-3 col-md-10 col-md-offset-2' + end + end + + def main_sidebar_classes + if cookies['sidebar-minimized'] == 'true' + 'col-xs-3 col-md-2 hidden-xs sidebar' + else + 'col-xs-3 col-md-2 sidebar' + end + end + + def wrapper_classes + 'sidebar-minimized' if cookies['sidebar-minimized'] == 'true' + end + end + end +end diff --git a/backend/app/helpers/spree/admin/orders_helper.rb b/backend/app/helpers/spree/admin/orders_helper.rb new file mode 100644 index 00000000000..def30ec0b69 --- /dev/null +++ b/backend/app/helpers/spree/admin/orders_helper.rb @@ -0,0 +1,69 @@ +module Spree + module Admin + module OrdersHelper + # Renders all the extension partials that may have been specified in the extensions + def event_links(order, events) + links = [] + events.sort.each do |event| + next unless order.send("can_#{event}?") + + label = Spree.t(event, scope: 'admin.order.events', default: Spree.t(event)) + links << button_link_to( + label.capitalize, + [event, :admin, order], + method: :put, + icon: event.to_s, + data: { confirm: Spree.t(:order_sure_want_to, event: label) } + ) + end + safe_join(links, ' '.html_safe) + end + + def line_item_shipment_price(line_item, quantity) + Spree::Money.new(line_item.price * quantity, currency: line_item.currency) + end + + def avs_response_code + { + 'A' => 'Street address matches, but 5-digit and 9-digit postal code do not match.', + 'B' => 'Street address matches, but postal code not verified.', + 'C' => 'Street address and postal code do not match.', + 'D' => 'Street address and postal code match. ', + 'E' => 'AVS data is invalid or AVS is not allowed for this card type.', + 'F' => "Card member's name does not match, but billing postal code matches.", + 'G' => 'Non-U.S. issuing bank does not support AVS.', + 'H' => "Card member's name does not match. Street address and postal code match.", + 'I' => 'Address not verified.', + 'J' => "Card member's name, billing address, and postal code match.", + 'K' => "Card member's name matches but billing address and billing postal code do not match.", + 'L' => "Card member's name and billing postal code match, but billing address does not match.", + 'M' => 'Street address and postal code match. ', + 'N' => 'Street address and postal code do not match.', + 'O' => "Card member's name and billing address match, but billing postal code does not match.", + 'P' => 'Postal code matches, but street address not verified.', + 'Q' => "Card member's name, billing address, and postal code match.", + 'R' => 'System unavailable.', + 'S' => 'Bank does not support AVS.', + 'T' => "Card member's name does not match, but street address matches.", + 'U' => 'Address information unavailable. Returned if the U.S. bank does not support non-U.S. AVS or if the AVS in a U.S. bank is not functioning properly.', + 'V' => "Card member's name, billing address, and billing postal code match.", + 'W' => 'Street address does not match, but 9-digit postal code matches.', + 'X' => 'Street address and 9-digit postal code match.', + 'Y' => 'Street address and 5-digit postal code match.', + 'Z' => 'Street address does not match, but 5-digit postal code matches.' + } + end + + def cvv_response_code + { + 'M' => 'CVV2 Match', + 'N' => 'CVV2 No Match', + 'P' => 'Not Processed', + 'S' => 'Issuer indicates that CVV2 data should be present on the card, but the merchant has indicated data is not present on the card', + 'U' => 'Issuer has not certified for CVV2 or Issuer has not provided Visa with the CVV2 encryption keys', + '' => 'Transaction failed because wrong CVV2 number was entered or no CVV2 number was entered' + } + end + end + end +end diff --git a/backend/app/helpers/spree/admin/payments_helper.rb b/backend/app/helpers/spree/admin/payments_helper.rb new file mode 100644 index 00000000000..56d9c03f6fd --- /dev/null +++ b/backend/app/helpers/spree/admin/payments_helper.rb @@ -0,0 +1,11 @@ +module Spree + module Admin + module PaymentsHelper + def payment_method_name(payment) + # HACK: to allow us to retrieve the name of a "deleted" payment method + id = payment.payment_method_id + Spree::PaymentMethod.find_with_destroyed(id).name + end + end + end +end diff --git a/backend/app/helpers/spree/admin/promotion_rules_helper.rb b/backend/app/helpers/spree/admin/promotion_rules_helper.rb new file mode 100644 index 00000000000..91725300413 --- /dev/null +++ b/backend/app/helpers/spree/admin/promotion_rules_helper.rb @@ -0,0 +1,12 @@ +module Spree + module Admin + module PromotionRulesHelper + def options_for_promotion_rule_types(promotion) + existing = promotion.rules.map { |rule| rule.class.name } + rule_names = Rails.application.config.spree.promotions.rules.map(&:name).reject { |r| existing.include? r } + options = rule_names.map { |name| [Spree.t("promotion_rule_types.#{name.demodulize.underscore}.name"), name] } + options_for_select(options) + end + end + end +end diff --git a/backend/app/helpers/spree/admin/reimbursement_type_helper.rb b/backend/app/helpers/spree/admin/reimbursement_type_helper.rb new file mode 100644 index 00000000000..43ddab201ff --- /dev/null +++ b/backend/app/helpers/spree/admin/reimbursement_type_helper.rb @@ -0,0 +1,9 @@ +module Spree + module Admin + module ReimbursementTypeHelper + def reimbursement_type_name(reimbursement_type) + reimbursement_type.present? ? reimbursement_type.name.humanize : '' + end + end + end +end diff --git a/backend/app/helpers/spree/admin/reimbursements_helper.rb b/backend/app/helpers/spree/admin/reimbursements_helper.rb new file mode 100644 index 00000000000..99890c933ef --- /dev/null +++ b/backend/app/helpers/spree/admin/reimbursements_helper.rb @@ -0,0 +1,14 @@ +module Spree + module Admin + module ReimbursementsHelper + def reimbursement_status_color(reimbursement) + case reimbursement.reimbursement_status + when 'reimbursed' then 'success' + when 'pending' then 'notice' + when 'errored' then 'error' + else raise "unknown reimbursement status: #{reimbursement.reimbursement_status}" + end + end + end + end +end diff --git a/backend/app/helpers/spree/admin/stock_locations_helper.rb b/backend/app/helpers/spree/admin/stock_locations_helper.rb new file mode 100644 index 00000000000..0dd4a271619 --- /dev/null +++ b/backend/app/helpers/spree/admin/stock_locations_helper.rb @@ -0,0 +1,15 @@ +module Spree + module Admin + module StockLocationsHelper + def display_name(stock_location) + name_parts = [stock_location.admin_name, stock_location.name] + name_parts.delete_if(&:blank?) + name_parts.join(' / ') + end + + def state(stock_location) + stock_location.active? ? 'active' : 'inactive' + end + end + end +end diff --git a/backend/app/helpers/spree/admin/stock_movements_helper.rb b/backend/app/helpers/spree/admin/stock_movements_helper.rb new file mode 100644 index 00000000000..445422345e2 --- /dev/null +++ b/backend/app/helpers/spree/admin/stock_movements_helper.rb @@ -0,0 +1,24 @@ +module Spree + module Admin + module StockMovementsHelper + def pretty_originator(stock_movement) + if stock_movement.originator.respond_to?(:number) + if stock_movement.originator.respond_to?(:order) + link_to stock_movement.originator.number, [:edit, :admin, stock_movement.originator.order] + else + stock_movement.originator.number + end + else + '' + end + end + + def display_variant(stock_movement) + variant = stock_movement.stock_item.variant + output = [variant.name] + output << variant.options_text unless variant.options_text.blank? + safe_join(output, '
    '.html_safe) + end + end + end +end diff --git a/backend/app/helpers/spree/admin/taxons_helper.rb b/backend/app/helpers/spree/admin/taxons_helper.rb new file mode 100644 index 00000000000..c9a8ebe8b4e --- /dev/null +++ b/backend/app/helpers/spree/admin/taxons_helper.rb @@ -0,0 +1,9 @@ +module Spree + module Admin + module TaxonsHelper + def taxon_path(taxon) + taxon.ancestors.reverse.collect(&:name).join(' >> ') + end + end + end +end diff --git a/backend/app/models/spree/admin/resource.rb b/backend/app/models/spree/admin/resource.rb new file mode 100644 index 00000000000..e31a0c528f7 --- /dev/null +++ b/backend/app/models/spree/admin/resource.rb @@ -0,0 +1,36 @@ +module Spree + module Admin + class Resource + def initialize(controller_path, controller_name, parent_model, object_name = nil) + @controller_path = controller_path + @controller_name = controller_name + @parent_model = parent_model + @object_name = object_name + end + + def sub_namespace_parts + @controller_path.split('/')[2..-2] + end + + def model_class + sub_namespace = sub_namespace_parts.map(&:capitalize).join('::') + sub_namespace = "#{sub_namespace}::" unless sub_namespace.empty? + "Spree::#{sub_namespace}#{@controller_name.classify}".constantize + end + + def model_name + sub_namespace = sub_namespace_parts.join('/') + sub_namespace = "#{sub_namespace}/" unless sub_namespace.empty? + @parent_model.gsub("spree/#{sub_namespace}", '') + end + + def object_name + return @object_name if @object_name + + sub_namespace = sub_namespace_parts.join('_') + sub_namespace = "#{sub_namespace}_" unless sub_namespace.empty? + "#{sub_namespace}#{@controller_name.singularize}" + end + end + end +end diff --git a/backend/app/models/spree/backend_configuration.rb b/backend/app/models/spree/backend_configuration.rb new file mode 100644 index 00000000000..4813d41926e --- /dev/null +++ b/backend/app/models/spree/backend_configuration.rb @@ -0,0 +1,22 @@ +module Spree + class BackendConfiguration < Preferences::Configuration + preference :locale, :string, default: Rails.application.config.i18n.default_locale + + ORDER_TABS ||= [:orders, :payments, :creditcard_payments, + :shipments, :credit_cards, :return_authorizations, + :customer_returns, :adjustments, :customer_details] + PRODUCT_TABS ||= [:products, :option_types, :properties, :prototypes, + :variants, :product_properties, :taxonomies, + :taxons] + REPORT_TABS ||= [:reports] + CONFIGURATION_TABS ||= [:configurations, :general_settings, :tax_categories, + :tax_rates, :zones, :countries, :states, + :payment_methods, :shipping_methods, + :shipping_categories, :stock_transfers, + :stock_locations, :trackers, :refund_reasons, + :reimbursement_types, :return_authorization_reasons, + :stores] + PROMOTION_TABS ||= [:promotions, :promotion_categories] + USER_TABS ||= [:users] + end +end diff --git a/backend/app/views/kaminari/_first_page.html.erb b/backend/app/views/kaminari/_first_page.html.erb new file mode 100644 index 00000000000..7c6f7c1282b --- /dev/null +++ b/backend/app/views/kaminari/_first_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "First" page + - available local variables + url: url to the first page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %> +
  • diff --git a/backend/app/views/kaminari/_gap.html.erb b/backend/app/views/kaminari/_gap.html.erb new file mode 100644 index 00000000000..ce5324e38a4 --- /dev/null +++ b/backend/app/views/kaminari/_gap.html.erb @@ -0,0 +1,8 @@ +<%# Non-link tag that stands for skipped pages... + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • ..
  • diff --git a/backend/app/views/kaminari/_last_page.html.erb b/backend/app/views/kaminari/_last_page.html.erb new file mode 100644 index 00000000000..d8fc4195fd0 --- /dev/null +++ b/backend/app/views/kaminari/_last_page.html.erb @@ -0,0 +1,11 @@ +<%# Link to the "Last" page + - available local variables + url: url to the last page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %> +
  • diff --git a/backend/app/views/kaminari/_next_page.html.erb b/backend/app/views/kaminari/_next_page.html.erb new file mode 100644 index 00000000000..cf0e2301155 --- /dev/null +++ b/backend/app/views/kaminari/_next_page.html.erb @@ -0,0 +1,15 @@ +<%# Link to the "Next" page + - available local variables + url: url to the next page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> + diff --git a/backend/app/views/kaminari/_page.html.erb b/backend/app/views/kaminari/_page.html.erb new file mode 100644 index 00000000000..842469ba93f --- /dev/null +++ b/backend/app/views/kaminari/_page.html.erb @@ -0,0 +1,12 @@ +<%# Link showing page number + - available local variables + page: a page object for "this" page + url: url to this page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> +
  • + <%= link_to page, url, {remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil} %> +
  • diff --git a/backend/app/views/kaminari/_paginator.html.erb b/backend/app/views/kaminari/_paginator.html.erb new file mode 100644 index 00000000000..6645d6e990a --- /dev/null +++ b/backend/app/views/kaminari/_paginator.html.erb @@ -0,0 +1,23 @@ +<%# The container tag + - available local variables + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote + paginator: the paginator that renders the pagination tags inside +-%> +<%= paginator.render do %> +
      + <%= first_page_tag unless current_page.first? %> + <%= prev_page_tag unless current_page.first? %> + <% each_page do |page| %> + <% if page.left_outer? || page.right_outer? || page.inside_window? %> + <%= page_tag page %> + <% elsif !page.was_truncated? %> + <%= gap_tag %> + <% end %> + <% end %> + <%= next_page_tag unless current_page.last? %> + <%= last_page_tag unless current_page.last? %> +
    +<% end %> diff --git a/backend/app/views/kaminari/_prev_page.html.erb b/backend/app/views/kaminari/_prev_page.html.erb new file mode 100644 index 00000000000..1e8c0f33eb9 --- /dev/null +++ b/backend/app/views/kaminari/_prev_page.html.erb @@ -0,0 +1,15 @@ +<%# Link to the "Previous" page + - available local variables + url: url to the previous page + current_page: a page object for the currently displayed page + total_pages: total number of pages + per_page: number of items to fetch per page + remote: data-remote +-%> + diff --git a/backend/app/views/spree/admin/adjustments/_adjustment.html.erb b/backend/app/views/spree/admin/adjustments/_adjustment.html.erb new file mode 100644 index 00000000000..4353612e31b --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/_adjustment.html.erb @@ -0,0 +1,17 @@ +<% + @edit_url = edit_admin_order_adjustment_path(@order, adjustment) + @delete_url = admin_order_adjustment_path(@order, adjustment) +%> + + + <%= display_adjustable(adjustment.adjustable) %> + <%= adjustment.label %> + <%= adjustment.display_amount.to_html %> + <%= adjustment.state %> + + <% if adjustment.open? %> + <%= link_to_edit(adjustment, no_text: true) if can?(:edit, adjustment) %> + <%= link_to_delete(adjustment, no_text: true) if can?(:delete, adjustment) %> + <% end %> + + diff --git a/backend/app/views/spree/admin/adjustments/_adjustments_table.html.erb b/backend/app/views/spree/admin/adjustments/_adjustments_table.html.erb new file mode 100644 index 00000000000..39bd614d6cb --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/_adjustments_table.html.erb @@ -0,0 +1,31 @@ +
    +

    <%= Spree.t(:order_adjustments) %>

    +
    + + + + + + + + + + + + + <%= render partial: "adjustment", collection: @adjustments %> + +
    <%= Spree.t(:adjustable) %><%= Spree.t(:description) %><%= Spree.t(:amount) %><%= Spree.t(:status) %>
    + +<% if can?(:edit, Spree::Adjustment) %> + +<% end %> diff --git a/backend/app/views/spree/admin/adjustments/_form.html.erb b/backend/app/views/spree/admin/adjustments/_form.html.erb new file mode 100644 index 00000000000..51347474be5 --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/_form.html.erb @@ -0,0 +1,12 @@ +
    + <%= f.field_container :amount, class: ['form-group'] do %> + <%= f.label :amount, raw(Spree.t(:amount) + content_tag(:span, " *", class: "required")) %> + <%= text_field :adjustment, :amount, class: 'form-control' %> + <%= f.error_message_on :amount %> + <% end %> + <%= f.field_container :label, class: ['form-group'] do %> + <%= f.label :label, raw(Spree.t(:description) + content_tag(:span, " *", class: "required")) %> + <%= text_field :adjustment, :label, class: 'form-control' %> + <%= f.error_message_on :label %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/adjustments/edit.html.erb b/backend/app/views/spree/admin/adjustments/edit.html.erb new file mode 100644 index 00000000000..2477c71b398 --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/edit.html.erb @@ -0,0 +1,19 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :adjustments } %> + +<% content_for :page_title do %> + / <%= Spree.t(:edit) %> <%= Spree.t(:adjustment) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @adjustment } %> + +<%= form_for @adjustment, url: admin_order_adjustment_path(@order, @adjustment), method: :put do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button Spree.t(:continue), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_adjustments_url(@order), icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/adjustments/index.html.erb b/backend/app/views/spree/admin/adjustments/index.html.erb new file mode 100644 index 00000000000..0675dd4bd93 --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/index.html.erb @@ -0,0 +1,34 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :adjustments} %> + +<% content_for :page_title do %> + / <%= plural_resource_name(Spree::Adjustment) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to(Spree.t(:new_adjustment), new_admin_order_adjustment_url(@order), class: "btn-success", icon: 'add') if can? :create, Spree::Adjustment %> +<% end %> + +<% if @adjustments.present? %> +
    + <%= render partial: 'adjustments_table' %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Adjustment)) %> +
    +<% end %> + +<% if @order.can_add_coupon? %> +
    +
    + <%= text_field_tag "coupon_code", "", placeholder: Spree.t(:coupon_code), class: "form-control" %> +
    + <%= button Spree.t(:add_coupon_code), 'plus', 'submit', id: "add_coupon_code" %> +
    +<% end %> + +<%= javascript_tag do %> + var order_number = '<%= @order.number %>'; +<% end %> + +<%= render partial: 'spree/admin/shared/order_summary' %> diff --git a/backend/app/views/spree/admin/adjustments/new.html.erb b/backend/app/views/spree/admin/adjustments/new.html.erb new file mode 100644 index 00000000000..906fbf73930 --- /dev/null +++ b/backend/app/views/spree/admin/adjustments/new.html.erb @@ -0,0 +1,19 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :adjustments } %> + +<% content_for :page_title do %> + / <%= Spree.t(:new_adjustment) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @adjustment } %> + +<%= form_for @adjustment, url: admin_order_adjustments_path do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button Spree.t(:continue), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_adjustments_url(@order), icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/countries/_form.html.erb b/backend/app/views/spree/admin/countries/_form.html.erb new file mode 100644 index 00000000000..ee9f5cd2cdf --- /dev/null +++ b/backend/app/views/spree/admin/countries/_form.html.erb @@ -0,0 +1,20 @@ +
    + <%= f.field_container :name, class: ['form-group'], 'data-hook' => 'name' do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + + <%= f.field_container :iso_name, class: ['form-group'], 'data-hook' => 'iso_name' do %> + <%= f.label :iso_name, Spree.t(:iso_name) %> + <%= f.text_field :iso_name, class: 'form-control' %> + <%= f.error_message_on :iso_name %> + <% end %> + +
    + +
    +
    diff --git a/backend/app/views/spree/admin/countries/edit.html.erb b/backend/app/views/spree/admin/countries/edit.html.erb new file mode 100644 index 00000000000..e730a614e28 --- /dev/null +++ b/backend/app/views/spree/admin/countries/edit.html.erb @@ -0,0 +1,14 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:countries), spree.admin_countries_url %> / + <%= @country.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @country } %> + +<%= form_for [:admin, @country] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/countries/index.html.erb b/backend/app/views/spree/admin/countries/index.html.erb new file mode 100644 index 00000000000..65b0d9d3bb0 --- /dev/null +++ b/backend/app/views/spree/admin/countries/index.html.erb @@ -0,0 +1,31 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Country) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_country), new_object_url, { class: "btn-success", icon: 'add', id: 'admin_new_country' } %> +<% end if can? :create, Spree::Country %> + + + + + + + + + + + + <% @countries.each do |country| %> + + + + + + + <% end %> + +
    <%= Spree.t(:country_name) %><%= Spree.t(:iso_name) %><%= Spree.t(:states_required) %>
    <%= country.name %><%= country.iso_name %><%= country.states_required? ? Spree.t(:say_yes) : Spree.t(:say_no) %> + <%= link_to_edit(country, no_text: true) if can? :edit, country %> + <%= link_to_delete(country, no_text: true) if can? :delete, country %> +
    diff --git a/backend/app/views/spree/admin/countries/new.html.erb b/backend/app/views/spree/admin/countries/new.html.erb new file mode 100644 index 00000000000..986c1db11ed --- /dev/null +++ b/backend/app/views/spree/admin/countries/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:countries), spree.admin_countries_url %> / + <%= Spree.t(:new_country) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @country } %> + +<%= form_for [:admin, @country] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/customer_returns/_reimbursements_table.html.erb b/backend/app/views/spree/admin/customer_returns/_reimbursements_table.html.erb new file mode 100644 index 00000000000..1fda9ab6ff8 --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/_reimbursements_table.html.erb @@ -0,0 +1,36 @@ + + + + + + + + + + + + <% reimbursements.each do |reimbursement| %> + + + + + + + + <% end %> + +
    <%= Spree.t(:number) %><%= Spree.t(:total) %><%= Spree.t(:status) %><%= "#{Spree.t('date')}/#{Spree.t('time')}" %>
    + <% if reimbursement.reimbursed? %> + <%= link_to reimbursement.number, url_for([:admin, @order, reimbursement]) %> + <% else %> + <%= reimbursement.number %> + <% end %> + <%= reimbursement.display_total %> + + <%= reimbursement.reimbursement_status %> + + <%= pretty_time(reimbursement.created_at) %> + <% if !reimbursement.reimbursed? %> + <%= link_to_edit_url(url_for([:edit, :admin, @order, reimbursement]), title: "admin_edit_#{dom_id(reimbursement)}", no_text: true) if can?(:edit, reimbursement) %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/customer_returns/_return_item_decision.html.erb b/backend/app/views/spree/admin/customer_returns/_return_item_decision.html.erb new file mode 100644 index 00000000000..179e52ac058 --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/_return_item_decision.html.erb @@ -0,0 +1,50 @@ + + + + + + + + + + <% if show_decision %> + + <% end %> + + + + <% return_items.each do |return_item| %> + + + + + + + + <% if show_decision %> + + <% end %> + + <% end %> + +
    <%= Spree.t(:product) %><%= Spree.t(:sku) %><%= Spree.t(:pre_tax_amount) %><%= Spree.t(:preferred_reimbursement_type) %><%= Spree.t(:exchange_for) %><%= Spree.t(:acceptance_errors) %>
    +
    <%= return_item.inventory_unit.variant.name %>
    +
    <%= return_item.inventory_unit.variant.options_text %>
    +
    + <%= return_item.inventory_unit.variant.sku %> + + <%= return_item.display_pre_tax_amount %> + + <%= reimbursement_type_name(return_item.preferred_reimbursement_type) %> + + <%= return_item.exchange_variant.try(:exchange_name) %> + + <%= return_item.acceptance_status_errors %> + + <%= button_to [:admin, return_item], { class: 'with-tip display-inline btn btn-success btn-sm', params: { "return_item[acceptance_status]" => 'accepted' }, "data-action" => 'save', title: Spree.t(:accept), method: 'put' } do %> + <%= Spree.t(:accept) %> + <% end if can?(:accept, return_item) %> + <%= button_to [:admin, return_item], { class: 'with-tip display-inline btn btn-danger btn-sm', params: { "return_item[acceptance_status]" => 'rejected' }, "data-action" => 'remove', title: Spree.t(:reject), method: 'put' } do %> + <%= Spree.t(:reject) %> + <% end if can?(:reject, return_item) %> +
    diff --git a/backend/app/views/spree/admin/customer_returns/_return_item_selection.html.erb b/backend/app/views/spree/admin/customer_returns/_return_item_selection.html.erb new file mode 100644 index 00000000000..a66375df118 --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/_return_item_selection.html.erb @@ -0,0 +1,45 @@ + + + + + + + + + + + + + <%= f.fields_for :return_items, return_items do |item_fields| %> + <% return_item = item_fields.object %> + + + + + + + + + <% end %> + +
    + <%= check_box_tag 'select-all' %> + <%= Spree.t(:product) %><%= Spree.t(:sku) %><%= Spree.t(:pre_tax_amount) %><%= Spree.t(:exchange_for) %><%= Spree.t(:resellable) %>
    +
    + <%= item_fields.hidden_field :inventory_unit_id %> + <%= item_fields.hidden_field :return_authorization_id %> + <%= item_fields.hidden_field :pre_tax_amount %> +
    + <%= item_fields.check_box :returned, {checked: false, class: 'add-item', "data-price" => return_item.pre_tax_amount}, '1', '0' %> +
    +
    <%= return_item.inventory_unit.variant.name %>
    +
    <%= return_item.inventory_unit.variant.options_text %>
    +
    + <%= return_item.inventory_unit.variant.sku %> + + <%= return_item.display_pre_tax_amount %> + + <%= return_item.exchange_variant.try(:exchange_name) %> + + <%= item_fields.check_box :resellable, { checked: return_item.resellable } %> +
    diff --git a/backend/app/views/spree/admin/customer_returns/edit.html.erb b/backend/app/views/spree/admin/customer_returns/edit.html.erb new file mode 100644 index 00000000000..f155f0de61a --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/edit.html.erb @@ -0,0 +1,61 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :customer_returns } %> + +<% content_for :page_title do %> + / <%= Spree.t(:customer_return) %> #<%= @customer_return.number %> +<% end %> + +<% if @manual_intervention_return_items.any? %> +
    + <%= Spree.t(:manual_intervention_required) %> + <%= render partial: 'return_item_decision', locals: {return_items: @manual_intervention_return_items, show_decision: true} %> +
    +<% end %> + +<% if @pending_return_items.any? %> +
    + <%= Spree.t(:pending) %> + <%= render partial: 'return_item_decision', locals: {return_items: @pending_return_items, show_decision: true} %> +
    +<% end %> + +<% if @accepted_return_items.any? %> +
    + <%= Spree.t(:accepted) %> + <%= render partial: 'return_item_decision', locals: {return_items: @accepted_return_items, show_decision: false} %> +
    +<% end %> + +<% if @rejected_return_items.any? %> +
    + <%= Spree.t(:rejected) %> + <%= render partial: 'return_item_decision', locals: {return_items: @rejected_return_items, show_decision: false} %> +
    +<% end %> + +
    + <%= plural_resource_name(Spree::Reimbursement) %> + <% if @customer_return.reimbursements.any? %> + <%= render partial: 'reimbursements_table', locals: {reimbursements: @customer_return.reimbursements} %> + <% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Reimbursement)) %> +
    + <% end %> +
    + +<% if !@customer_return.fully_reimbursed? && @pending_reimbursements.empty? && can?(:create, Spree::Reimbursement) %> +
    + <% if @customer_return.completely_decided? %> + <%= form_for [:admin, @order, Spree::Reimbursement.new] do |f| %> + <%= hidden_field_tag :build_from_customer_return_id, @customer_return.id %> + <%= f.button class: 'btn btn-primary' do %> + <%= Spree.t(:create_reimbursement) %> + <% end %> + <% end %> + <% else %> +
    + <%= Spree.t(:unable_to_create_reimbursements) %> +
    + <% end %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/customer_returns/index.html.erb b/backend/app/views/spree/admin/customer_returns/index.html.erb new file mode 100644 index 00000000000..8ae4300ee74 --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/index.html.erb @@ -0,0 +1,56 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :customer_returns } %> + +<% content_for :page_actions do %> + <% if @order.shipments.any?(&:shipped?) && can?(:create, Spree::CustomerReturn) %> + <%= button_link_to Spree.t(:new_customer_return), spree.new_admin_order_customer_return_path(@order), icon: 'add', class: 'btn-success' %> + <% end %> +<% end %> + +<% content_for :page_title do %> + / <%= plural_resource_name(Spree::CustomerReturn) %> +<% end %> + +<% if @order.shipments.any?(&:shipped?) %> + + <% if @customer_returns.any? %> + + + + + + + + + + + + <% @customer_returns.each do |customer_return| %> + + + + + + + + <% end %> + +
    <%= Spree.t(:return_number) %><%= Spree.t(:pre_tax_total) %><%= "#{Spree.t('date')}/#{Spree.t('time')}" %><%= Spree.t(:reimbursement_status) %>
    <%= link_to customer_return.number, edit_admin_order_customer_return_path(@order, customer_return) %><%= customer_return.display_pre_tax_total.to_html %><%= pretty_time(customer_return.created_at) %> + <% if customer_return.fully_reimbursed? %> + <%= Spree.t(:reimbursed) %> + <% else %> + <%= Spree.t(:incomplete) %> + <% end %> + + <%= link_to_edit_url(edit_admin_order_customer_return_path(@order, customer_return), title: "admin_edit_#{dom_id(customer_return)}", no_text: true) if can?(:edit, customer_return) %> +
    + <% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::CustomerReturn)) %>, + <%= link_to(Spree.t(:add_one), spree.new_admin_order_customer_return_path(@order)) if can?(:create, Spree::CustomerReturn) %>! +
    + <% end %> +<% else %> +
    + <%= Spree.t(:cannot_create_customer_returns) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/customer_returns/new.html.erb b/backend/app/views/spree/admin/customer_returns/new.html.erb new file mode 100644 index 00000000000..d64424fc85a --- /dev/null +++ b/backend/app/views/spree/admin/customer_returns/new.html.erb @@ -0,0 +1,47 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :customer_returns } %> + +<% content_for :page_title do %> + / <%= Spree.t(:new_customer_return) %> +<% end %> + +<% if @rma_return_items.any? %> + + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @customer_return } %> + + <%= form_for [:admin, @order, @customer_return] do |f| %> +
    +
    +
    + <%= Spree.t(:items_in_rmas) %> + <% if @rma_return_items.any? %> + <%= render partial: 'return_item_selection', locals: {f: f, return_items: @rma_return_items} %> + <% else %> +
    + <%= Spree.t(:none) %> +
    + <% end %> +
    + + <%= f.field_container :stock_location, class: ['form-group'] do %> + <%= f.label :stock_location, Spree.t(:stock_location) %> * + <%= f.select :stock_location_id, Spree::StockLocation.order_default.active.to_a.collect{|l|[l.name.humanize, l.id]}, {include_blank: true}, {class: 'select2', "data-placeholder" => Spree.t(:select_a_stock_location)} %> + <%= f.error_message_on :stock_location %> + <% end %> +
    + +
    + <%= button Spree.t(:create), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_customer_returns_url(@order), icon: 'delete' %> +
    +
    + <% end %> + +<% else %> + +
    + <%= Spree.t(:all_items_have_been_returned) %>, + <%= link_to Spree.t(:back_to_resource_list, resource: Spree::CustomerReturn.model_name.human), spree.admin_order_customer_returns_path(@order) %>. +
    + +<% end %> diff --git a/backend/app/views/spree/admin/general_settings/edit.html.erb b/backend/app/views/spree/admin/general_settings/edit.html.erb new file mode 100644 index 00000000000..19bd9912714 --- /dev/null +++ b/backend/app/views/spree/admin/general_settings/edit.html.erb @@ -0,0 +1,93 @@ +<% content_for :page_title do %> + <%= Spree.t(:general_settings) %> +<% end %> + +<%= form_tag admin_general_settings_path, method: :put do %> +
    + +
    + +
    +
    + <%#-------------------------------------------------%> + <%# Security settings %> + <%#-------------------------------------------------%> + <% if @preferences_security.any? %> +
    +
    +

    + <%= Spree.t(:security_settings) %> +

    +
    + +
    + <% @preferences_security.each do |key| + type = Spree::Config.preference_type(key) %> +
    + <%= label_tag key do %> + <%= preference_field_tag(key, Spree::Config[key], type: type) %> + <%= Spree.t(key) %> + <% end %> +
    + <% end %> +
    +
    + <% end %> + + <%#-------------------------------------------------%> + <%# Clear cache %> + <%#-------------------------------------------------%> +
    +
    +

    <%= Spree.t(:clear_cache)%>

    +
    + +
    +
    + <%= Spree.t(:clear_cache_warning) %> +
    +
    + <%= button Spree.t(:clear_cache), 'ok', 'button', id: "clear_cache" %> +
    +
    +
    + +
    +
    + + <%#-------------------------------------------------%> + <%# Currency Settings %> + <%#-------------------------------------------------%> +
    +
    +

    + <%= Spree.t(:currency_settings)%> +

    +
    + +
    +
    + <%= label_tag :currency, Spree.t(:choose_currency) %> + <%= select_tag :currency, currency_options %> +
    +
    +
    +
    +
    + +
    + <%= button Spree.t('actions.update'), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), edit_admin_general_settings_url, icon: 'delete' %> +
    + +
    + +
    + +<% end %> + + diff --git a/backend/app/views/spree/admin/images/_form.html.erb b/backend/app/views/spree/admin/images/_form.html.erb new file mode 100644 index 00000000000..97d8b45bd84 --- /dev/null +++ b/backend/app/views/spree/admin/images/_form.html.erb @@ -0,0 +1,18 @@ +
    +
    +
    + <%= f.label :attachment, Spree.t(:filename) %> + <%= f.file_field :attachment %> +
    +
    + <%= f.label :viewable_id, Spree::Variant.model_name.human %> + <%= f.select :viewable_id, @variants, {}, {class: 'select2'} %> +
    +
    +
    +
    + <%= f.label :alt, Spree.t(:alt_text) %> + <%= f.text_area :alt, rows: 4, class: 'form-control' %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/images/edit.html.erb b/backend/app/views/spree/admin/images/edit.html.erb new file mode 100644 index 00000000000..48cf9b9c2c8 --- /dev/null +++ b/backend/app/views/spree/admin/images/edit.html.erb @@ -0,0 +1,30 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :images } %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @image } %> + +<%= form_for [:admin, @product, @image], html: { multipart: true } do |f| %> +
    +
    +

    + <%= @image.attachment_file_name%> +

    +
    +
    +
    +
    + <%= f.label Spree.t(:thumbnail) %> + <%= link_to image_tag(main_app.url_for(@image.url(:small))), main_app.url_for(@image.url(:product)) %> +
    +
    + <%= render partial: 'form', locals: { f: f } %> +
    + +
    + <%= button Spree.t('actions.update'), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_product_images_url(@product), id: 'cancel_link', icon: 'delete' %> +
    +
    +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/images/index.html.erb b/backend/app/views/spree/admin/images/index.html.erb new file mode 100644 index 00000000000..3bfd37da3f7 --- /dev/null +++ b/backend/app/views/spree/admin/images/index.html.erb @@ -0,0 +1,55 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :images } %> + +<% content_for :page_actions do %> + <%= button_link_to(Spree.t(:new_image), spree.new_admin_product_image_url(@product), { class: "btn-success", icon: 'add', id: 'new_image_link' }) if can? :create, Spree::Image %> +<% end %> + +<% has_variants = @product.has_variants? %> + +<% unless @product.variant_images.any? %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Image)) %>. +
    +<% else %> + + + + + <% if has_variants %> + + <% end %> + + + + + + + <% (@product.variant_images).each do |image| %> + + + + <% if has_variants %> + + <% end %> + + + + <% end %> + +
    + <%= Spree.t(:thumbnail) %> + + <%= Spree::Variant.model_name.human %> + + <%= Spree.t(:alt_text) %> +
    + <% if can? :edit, image %> + + <% end %> + + <%= link_to image_tag(main_app.url_for(image.url(:mini))), main_app.url_for(image.url(:product)) %> + <%= options_text_for(image) %><%= image.alt %> + <%= link_to_with_icon('edit', Spree.t(:edit), spree.edit_admin_product_image_url(@product, image), class: 'btn btn-primary btn-sm', no_text: true, data: { action: 'edit' }) if can? :edit, image %> + <%= link_to_delete(image, { url: spree.admin_product_image_url(@product, image), no_text: true }) if can? :destroy, image %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/images/new.html.erb b/backend/app/views/spree/admin/images/new.html.erb new file mode 100644 index 00000000000..4c3252e1ec2 --- /dev/null +++ b/backend/app/views/spree/admin/images/new.html.erb @@ -0,0 +1,19 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :images } %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @object } %> + +<% content_for :page_title do %> + <%= Spree.t(:new_image) %> +<% end %> + +<%= form_for [:admin, @product, @image], html: { multipart: true } do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button Spree.t('actions.create'), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_product_images_url(@product), icon: 'delete', id: 'cancel_link' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/log_entries/index.html.erb b/backend/app/views/spree/admin/log_entries/index.html.erb new file mode 100644 index 00000000000..9fc3e32ea66 --- /dev/null +++ b/backend/app/views/spree/admin/log_entries/index.html.erb @@ -0,0 +1,33 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :payments }%> + +<% content_for :page_title do %> + / + <%= I18n.t(:one, scope: "activerecord.models.spree/payment") %> + / + <%= Spree.t(:log_entries) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:logs), spree.admin_order_payment_log_entries_url(@order, @payment), icon: 'file' %> +<% end %> + + + <% @log_entries.each do |entry| %> + + + + + + + + + + + + <% end %> +
    +

    + + <%= pretty_time(entry.created_at) %> +

    +
    Message<%= entry.parsed_details.message %>
    diff --git a/backend/app/views/spree/admin/option_types/_form.html.erb b/backend/app/views/spree/admin/option_types/_form.html.erb new file mode 100644 index 00000000000..74406d74509 --- /dev/null +++ b/backend/app/views/spree/admin/option_types/_form.html.erb @@ -0,0 +1,17 @@ +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: "form-control" %> + <%= f.error_message_on :name %> + <% end %> +
    + +
    + <%= f.field_container :presentation, class: ['form-group'] do %> + <%= f.label :presentation, Spree.t(:presentation) %> * + <%= f.text_field :presentation, class: "form-control" %> + <%= f.error_message_on :presentation %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/option_types/_option_value_fields.html.erb b/backend/app/views/spree/admin/option_types/_option_value_fields.html.erb new file mode 100644 index 00000000000..0330b6bd52f --- /dev/null +++ b/backend/app/views/spree/admin/option_types/_option_value_fields.html.erb @@ -0,0 +1,9 @@ + + + + <%= f.hidden_field :id %> + + <%= f.text_field :name, class: "form-control" %> + <%= f.text_field :presentation, class: "form-control" %> + <%= link_to_icon_remove_fields f %> + diff --git a/backend/app/views/spree/admin/option_types/edit.html.erb b/backend/app/views/spree/admin/option_types/edit.html.erb new file mode 100644 index 00000000000..0cfb20c9b6b --- /dev/null +++ b/backend/app/views/spree/admin/option_types/edit.html.erb @@ -0,0 +1,49 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:option_types), spree.admin_option_types_url %> / + <%= @option_type.name %> +<% end %> + +<% content_for :page_actions do %> + + <%= button_link_to Spree.t(:add_option_value), "javascript:;", { icon: 'add', :'data-target' => "tbody#option_values", class: 'btn-success spree_add_fields' } %> + +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @option_type } %> + +<%= form_for [:admin, @option_type] do |f| %> +
    +
    +

    + <%= Spree.t(:option_values) %> +

    +
    +
    + <%= render partial: 'form', locals: { f: f } %> +
    + + + + + + + + + + <% if @option_type.option_values.empty? %> + + + + + <% else %> + <%= f.fields_for :option_values do |option_value_form| %> + <%= render partial: 'option_value_fields', locals: { f: option_value_form } %> + <% end %> + <% end %> + +
    <%= Spree.t(:name) %> *<%= Spree.t(:display) %> *
    <%= Spree.t(:none) %>
    + +
    +<% end %> diff --git a/backend/app/views/spree/admin/option_types/index.html.erb b/backend/app/views/spree/admin/option_types/index.html.erb new file mode 100644 index 00000000000..65ede75fc2e --- /dev/null +++ b/backend/app/views/spree/admin/option_types/index.html.erb @@ -0,0 +1,44 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::OptionType) %> +<% end %> + +<% content_for :page_actions do %> + + <%= button_link_to Spree.t(:new_option_type), new_object_url, { class: "btn-success", icon: 'add', id: 'new_option_type_link' } %> + +<% end if can?(:create, Spree::OptionType) %> + +
    + +<% if @option_types.any? %> + + + + + + + + + + + <% @option_types.each do |option_type| %> + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:presentation) %>
    + + <%= option_type.name %><%= option_type.presentation %> + <%= link_to_edit(option_type, class: 'admin_edit_option_type', no_text: true) if can?(:edit, option_type) %> + <%= link_to_delete(option_type, no_text: true) if can?(:delete, option_type) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::OptionType)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::OptionType) %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/option_types/new.html.erb b/backend/app/views/spree/admin/option_types/new.html.erb new file mode 100644 index 00000000000..993286541aa --- /dev/null +++ b/backend/app/views/spree/admin/option_types/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:option_types), spree.admin_option_types_url %> / + <%= Spree.t(:new_option_type) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @option_type } %> + +<%= form_for [:admin, @option_type] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/orders/_add_line_item.html.erb b/backend/app/views/spree/admin/orders/_add_line_item.html.erb new file mode 100644 index 00000000000..374be210512 --- /dev/null +++ b/backend/app/views/spree/admin/orders/_add_line_item.html.erb @@ -0,0 +1,17 @@ +<%= render partial: "spree/admin/variants/autocomplete", formats: :js %> + +<%= render partial: "spree/admin/variants/autocomplete_line_items_stock", formats: :js %> + +
    +
    +

    <%= Spree.t(:add_product) %>

    +
    +
    +
    + <%= label_tag :add_line_item_variant_id, Spree.t(:name_or_sku) %> + <%= hidden_field_tag :add_line_item_variant_id, "", class: "variant_autocomplete fullwidth-input" %> +
    + +
    +
    +
    diff --git a/backend/app/views/spree/admin/orders/_add_product.html.erb b/backend/app/views/spree/admin/orders/_add_product.html.erb new file mode 100644 index 00000000000..0ed710e97f1 --- /dev/null +++ b/backend/app/views/spree/admin/orders/_add_product.html.erb @@ -0,0 +1,18 @@ +<%= render partial: "spree/admin/variants/autocomplete", formats: :js %> +<%= render partial: "spree/admin/variants/autocomplete_stock", formats: :js %> + +
    +
    +

    + <%= Spree.t(:add_product) %> +

    +
    +
    +
    + <%= label_tag :add_variant_id, Spree.t(:name_or_sku) %> + <%= hidden_field_tag :add_variant_id, "", class: "variant_autocomplete fullwidth-input" %> +
    + +
    +
    +
    diff --git a/backend/app/views/spree/admin/orders/_adjustments.html.erb b/backend/app/views/spree/admin/orders/_adjustments.html.erb new file mode 100644 index 00000000000..d4585fcc55f --- /dev/null +++ b/backend/app/views/spree/admin/orders/_adjustments.html.erb @@ -0,0 +1,35 @@ +<% if adjustments.eligible.exists? %> +
    +
    +

    + <%= title %> +

    +
    + + + + + + + + + + <% adjustments.eligible.group_by(&:label).each do |label, adjustments| %> + + + + + <% end %> + +
    <%= Spree.t('name')%><%= Spree.t('amount')%>
    + <%= label %>: + + + <%= Spree::Money.new( + adjustments.sum(&:amount), + currency: adjustments.first.order.try(:currency) + ) %> + +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/orders/_form.html.erb b/backend/app/views/spree/admin/orders/_form.html.erb new file mode 100644 index 00000000000..9205d43cbbb --- /dev/null +++ b/backend/app/views/spree/admin/orders/_form.html.erb @@ -0,0 +1,56 @@ +
    + <% if @line_item.try(:errors).present? %> + <%= render 'spree/admin/shared/error_messages', target: @line_item %> + <% end %> + + <% if Spree::Order.checkout_step_names.include?(:delivery) %> + <%= render partial: "spree/admin/orders/shipment", collection: @order.shipments.order(:created_at), locals: {order: order} %> + <% else %> + <%= render "spree/admin/orders/line_items", order: order %> + <% end %> + + <%= render "spree/admin/orders/adjustments", + adjustments: @order.line_item_adjustments, + order: order, + title: Spree.t(:line_item_adjustments) + %> + <%= render "spree/admin/orders/adjustments", + adjustments: @order.shipment_adjustments, + order: order, + title: Spree.t(:shipment_adjustments) + %> + <%= render "spree/admin/orders/adjustments", + adjustments: @order.adjustments, + order: order, + title: Spree.t(:order_adjustments) + %> + + <% if order.line_items.exists? %> +
    + <%= Spree.t(:order_total) %>: + + <%= order.display_total %> + +
    + <% end %> + + <%= javascript_tag do %> + var order_number = '<%= @order.number %>'; + var shipments = []; + + <% @order.shipments.each do |shipment| %> + shipments.push( + <%== shipment.as_json( + root: false, + only: [ + :id, :tracking, :number, :state, :stock_location_id + ], include: [ + :inventory_units, :stock_location + ]).to_json + %> + ); + <% end %> + + <%= render partial: 'spree/admin/shared/update_order_state', formats: [:js], handlers: [:erb] %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/orders/_line_items.html.erb b/backend/app/views/spree/admin/orders/_line_items.html.erb new file mode 100644 index 00000000000..25987cee89f --- /dev/null +++ b/backend/app/views/spree/admin/orders/_line_items.html.erb @@ -0,0 +1,52 @@ +<% if order.line_items.exists? %> +
    +
    +

    + <%= Spree.t(:order_line_items) %> +

    +
    +
    + + + + + + + + + + + + <% order.line_items.each do |item| %> + + + + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:price) %><%= Spree.t(:quantity) %><%= Spree.t(:total_price) %>
    + <%= mini_image(item.variant) %> + + <%= item.name %> +
    + <%= "(#{item.options_text})" if item.options_text.present? %> +
    <%= item.single_money.to_html %> + <%= item.quantity %> + <%= line_item_shipment_price(item, item.quantity) %> + <% if can? :update, item %> + <%= link_to_with_icon 'arrow-left', Spree.t('actions.cancel'), "#", class: 'cancel-line-item btn btn-default btn-sm', data: {action: 'cancel'}, title: Spree.t('actions.cancel'), style: 'display: none', no_text: true %> + <%= link_to_with_icon 'save', Spree.t('actions.save'), "#", class: 'save-line-item btn btn-success btn-sm', no_text: true, data: { :'line-item-id' => item.id, action: 'save'}, title: Spree.t('actions.save'), style: 'display: none' %> + <%= link_to_with_icon 'edit', Spree.t('edit'), "#", class: 'edit-line-item btn btn-primary btn-sm', data: {action: 'edit'}, title: Spree.t('edit'), no_text: true %> + <%= link_to_with_icon 'delete', Spree.t('delete'), "#", class: 'delete-line-item btn btn-danger btn-sm', data: { 'line-item-id' => item.id, action: 'remove'}, title: Spree.t('delete'), no_text: true %> + <% end %> +
    +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/orders/_line_items_edit_form.html.erb b/backend/app/views/spree/admin/orders/_line_items_edit_form.html.erb new file mode 100644 index 00000000000..67fe47556df --- /dev/null +++ b/backend/app/views/spree/admin/orders/_line_items_edit_form.html.erb @@ -0,0 +1,39 @@ +
    + <% if @line_item.try(:errors).present? %> + <%= render 'spree/admin/shared/error_messages', target: @line_item %> + <% end %> + + <%= render "spree/admin/orders/line_items", order: order %> + <%= render "spree/admin/orders/adjustments", + adjustments: @order.line_item_adjustments, + order: order, + title: Spree.t(:line_item_adjustments) + %> + <%= render "spree/admin/orders/adjustments", + adjustments: @order.shipment_adjustments, + order: order, + title: Spree.t(:shipment_adjustments) + %> + <%= render "spree/admin/orders/adjustments", + adjustments: @order.adjustments, + order: order, + title: Spree.t(:order_adjustments) + %> + + <% if order.line_items.exists? %> +
    + <%= Spree.t(:order_total) %>: <%= order.display_total %> +
    + <% end %> + + <%= javascript_tag do %> + var order_number = '<%= @order.number %>'; + var shipments = []; + + <% @order.shipments.each do |shipment| %> + shipments.push(<%== shipment.as_json(root: false, only: [:id, :tracking, :number, :state, :stock_location_id], include: [:inventory_units, :stock_location]).to_json %>); + <% end %> + + <%= render partial: 'spree/admin/shared/update_order_state', formats: [:js], handlers: [:erb] %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/orders/_order_actions.html.erb b/backend/app/views/spree/admin/orders/_order_actions.html.erb new file mode 100644 index 00000000000..3c31dac435c --- /dev/null +++ b/backend/app/views/spree/admin/orders/_order_actions.html.erb @@ -0,0 +1,12 @@ +<% content_for :page_actions do %> + <% if can?(:fire, order) %> + <%= event_links(order, events) %> + <% end %> + <% if can?(:resend, order) %> + <%= button_link_to Spree.t(:resend, scope: 'admin.order.events', default: Spree.t(:resend)), + resend_admin_order_url(order), + method: :post, + icon: 'envelope' + %> + <% end %> +<% end %> diff --git a/backend/app/views/spree/admin/orders/_risk_analysis.html.erb b/backend/app/views/spree/admin/orders/_risk_analysis.html.erb new file mode 100644 index 00000000000..46beb8e0201 --- /dev/null +++ b/backend/app/views/spree/admin/orders/_risk_analysis.html.erb @@ -0,0 +1,47 @@ +
    + <%= "#{Spree.t(:risk_analysis)}: #{Spree.t(:not) unless @order.is_risky?} #{Spree.t(:risky)}" %> + + + + + + + + + + + + + + + + + + + + + +
    <%= Spree.t('risk')%><%= Spree.t('status')%>
    + <%= Spree.t(:failed_payment_attempts) %>: + + + <%= link_to "#{Spree.t 'payments_count', count: @order.payments.failed.size, default: pluralize(@order.payments.failed.size, Spree.t(:payment))}", spree.admin_order_payments_path(@order) %> + +
    <%= Spree.t(:avs_response) %>: + + <% if latest_payment.is_avs_risky? %> + <%= "#{Spree.t(:error)}: #{avs_response_code[latest_payment.avs_response]}" %> + <% else %> + <%= Spree.t(:success) %> + <% end %> + +
    <%= Spree.t(:cvv_response) %>: + + <% if latest_payment.is_cvv_risky? %> + <%= "#{Spree.t(:error)}: #{cvv_response_code[latest_payment.cvv_response_code]}" %> + <% else %> + <%= Spree.t(:success) %> + <% end %> + +
    +
    diff --git a/backend/app/views/spree/admin/orders/_shipment.html.erb b/backend/app/views/spree/admin/orders/_shipment.html.erb new file mode 100644 index 00000000000..3780e1e1d71 --- /dev/null +++ b/backend/app/views/spree/admin/orders/_shipment.html.erb @@ -0,0 +1,111 @@ +
    " data-hook="admin_shipment_form" class="panel panel-default"> + + <%= render partial: "spree/admin/variants/split", formats: :js %> + +
    +

    + <%= shipment.number %> + - + <%= Spree.t("shipment_states.#{shipment.state}") %> + <%= Spree.t(:package_from) %> + '<%= shipment.stock_location.name %>' + <% if shipment.ready? and can? :update, shipment %> + <%= link_to Spree.t(:ship), 'javascript:;', class: 'ship pull-right btn btn-success', data: { 'shipment-number' => shipment.number } %> +
    + <% end %> +

    +
    + +
    + + + + + + + + + + + + + <%= render 'spree/admin/orders/shipment_manifest', shipment: shipment %> + + <% unless shipment.shipped? %> + + + + + <% end %> + + + <% if rate = shipment.selected_shipping_rate %> + + + <% else %> + + <% end %> + + + + + + + + + + + <% if order.special_instructions.present? %> + + + + <% end %> + + + + + + +
    <%= Spree.t(:item_description) %><%= Spree.t(:price) %><%= Spree.t(:quantity) %><%= Spree.t(:total) %>
    + <%= rate.name %> + + <%= shipment.display_cost %> + <%= Spree.t(:no_shipping_method_selected) %> + <% if( (can? :update, shipment) and !shipment.shipped?) %> + <%= link_to_with_icon 'edit', Spree.t('edit'), "javascript:;", class: 'edit-method with-tip btn btn-sm btn-primary', data: {action: 'edit'}, no_text: true %> + <% end %> +
    + <%= Spree.t(:special_instructions) %>: <%= order.special_instructions %> +
    + <% if shipment.tracking.present? %> + <%= Spree.t(:tracking) %>: <%= link_to_tracking(shipment, target: '_blank') %> + <% else %> + <%= Spree.t(:no_tracking_present) %> + <% end %> + + <% if can? :update, shipment %> + <%= link_to_with_icon 'edit', Spree.t('edit'), "#", class: 'edit-tracking btn btn-primary btn-sm', data: {action: 'edit'}, title: Spree.t('edit'), no_text: true %> + <% end %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/orders/_shipment_manifest.html.erb b/backend/app/views/spree/admin/orders/_shipment_manifest.html.erb new file mode 100644 index 00000000000..b15510ac374 --- /dev/null +++ b/backend/app/views/spree/admin/orders/_shipment_manifest.html.erb @@ -0,0 +1,44 @@ +<% shipment.manifest.each do |item| %> + + + <%= mini_image(item.variant) %> + + + + <%= item.variant.product.name %> +
    + <%= "(#{item.variant.options_text})" if item.variant.options_text.present? %> + <% if item.variant.sku.present? %> + <%= Spree.t(:sku) %>: <%= item.variant.sku %> + <% end %> + + + <%= item.line_item.single_money.to_html %> + + + <% item.states.each do |state,count| %> + <%= count %> x <%= Spree.t(state).downcase %> + <% end %> + + + <% unless shipment.shipped? %> + + <%= number_field_tag :quantity, item.quantity, min: 0, class: "line_item_quantity form-control", size: 5 %> + + <% end %> + + + <%= line_item_shipment_price(item.line_item, item.quantity) %> + + + + <% if((!shipment.shipped?) && can?(:update, item.line_item)) %> + <%= link_to_with_icon 'pencil', Spree.t('actions.edit'), "#", class: 'edit-item btn btn-default btn-sm', title: Spree.t('actions.edit'), no_text: true %> + <%= link_to_with_icon 'cancel', Spree.t('actions.cancel'), "#", class: 'cancel-item btn btn-primary btn-sm', data: { action: 'cancel' }, title: Spree.t('actions.cancel'), style: 'display: none', no_text: true %> + <%= link_to_with_icon 'ok', Spree.t('actions.save'), "#", class: 'save-item btn btn-success btn-sm', data: {'shipment-number' => shipment.number, 'variant-id' => item.variant.id, action: 'save'}, title: Spree.t('actions.save'), style: 'display: none', no_text: true %> + <%= link_to_with_icon 'split', Spree.t('split'), "#", class: 'split-item btn btn-primary btn-sm', data: {action: 'split', 'variant-id' => item.variant.id}, title: Spree.t('split'), no_text: true %> + <%= link_to_with_icon 'delete', Spree.t('delete'), "#", class: 'delete-item btn btn-danger btn-sm', data: { 'shipment-number' => shipment.number, 'variant-id' => item.variant.id, action: 'remove'}, title: Spree.t('delete'), no_text: true %> + <% end %> + + +<% end %> diff --git a/backend/app/views/spree/admin/orders/_store_form.html.erb b/backend/app/views/spree/admin/orders/_store_form.html.erb new file mode 100644 index 00000000000..601ada25efe --- /dev/null +++ b/backend/app/views/spree/admin/orders/_store_form.html.erb @@ -0,0 +1,18 @@ +
    + <%= form_for [:admin, order], method: :put, url: set_store_admin_order_url do |f| %> +
    +
    + <%= f.field_container :store_id, class: ['form-group'] do %> + <%= f.label :store_id, Spree.t(:store) %> + <%= f.collection_select(:store_id, @stores, :id, :name, { include_blank: false }, { class: "select2" }) %> + <%= f.error_message_on :store_id %> + <% end %> +
    +
    + <%= button Spree.t('actions.update'), 'refresh', 'submit', {class: 'btn-success', data: { disable_with: "#{ Spree.t(:saving) }..." }} %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), edit_admin_order_url(order), icon: 'delete' %> +
    +
    + <% end %> +
    \ No newline at end of file diff --git a/backend/app/views/spree/admin/orders/cart.html.erb b/backend/app/views/spree/admin/orders/cart.html.erb new file mode 100644 index 00000000000..2a2410ae749 --- /dev/null +++ b/backend/app/views/spree/admin/orders/cart.html.erb @@ -0,0 +1,27 @@ +<%= render 'order_actions', order: @order, events: @order_events %> + +<%= render 'spree/admin/shared/order_tabs', current: :cart %> + +
    + <%= render 'spree/admin/shared/error_messages', target: @order %> +
    + +<% if @order.payments.exists? && @order.considered_risky? %> + <%= render 'spree/admin/orders/risk_analysis', latest_payment: @order.payments.order("created_at DESC").first %> +<% end %> + +<%= render 'add_line_item' if can?(:update, @order) %> + +<% if @order.line_items.empty? %> +
    + <%= Spree.t(:your_order_is_empty_add_product)%> +
    +<% end %> + +
    +
    + <%= render 'line_items_edit_form', order: @order %> +
    +
    + +<%= render 'spree/admin/shared/order_summary' %> diff --git a/backend/app/views/spree/admin/orders/customer_details/_autocomplete.js.erb b/backend/app/views/spree/admin/orders/customer_details/_autocomplete.js.erb new file mode 100644 index 00000000000..9484fa0dbb3 --- /dev/null +++ b/backend/app/views/spree/admin/orders/customer_details/_autocomplete.js.erb @@ -0,0 +1,19 @@ + diff --git a/backend/app/views/spree/admin/orders/customer_details/_form.html.erb b/backend/app/views/spree/admin/orders/customer_details/_form.html.erb new file mode 100644 index 00000000000..38596922710 --- /dev/null +++ b/backend/app/views/spree/admin/orders/customer_details/_form.html.erb @@ -0,0 +1,106 @@ +
    + +
    + +
    +

    + <%= Spree.t(:account) %> +

    +
    + +
    +
    +
    +
    + <%= f.label :email, Spree.t(:email) %> + <% if can? :edit, @order.user %> + <%= f.email_field :email, class: 'form-control' %> + <% else %> +

    <%= @order.user.try(:email) || @order.email %>

    + <% end %> +
    +
    +
    +
    + <%= label_tag nil, Spree.t(:guest_checkout) %> + <% guest = @order.user.nil? %> + + <% if @order.completed? %> +
    + + <%= guest ? Spree.t(:say_yes) : Spree.t(:say_no) %> + <%= hidden_field_tag :guest_checkout, guest %> + +
    + <% else %> +
    + <%= label_tag :guest_checkout_true do %> + <%= radio_button_tag :guest_checkout, true, guest %> + <%= Spree.t(:say_yes) %> + <% end %> +
    +
    + <%= label_tag :guest_checkout_false do %> + <%= radio_button_tag :guest_checkout, false, !guest, disabled: @order.cart? %> + <%= Spree.t(:say_no) %> + <% end %> +
    + <%= f.hidden_field :user_id, value: @order.user_id %> + <% end %> +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +

    <%= Spree.t(:billing_address) %>

    +
    + +
    + <% if can? :edit, @order.user %> + <%= f.fields_for :bill_address do |ba_form| %> + <%= render 'spree/admin/shared/address_form', f: ba_form, type: "billing" %> + <% end %> + <% else %> + <%= render 'spree/admin/shared/address', address: @order.bill_address %> + <% end %> +
    +
    +
    + +
    +
    +
    +

    <%= Spree.t(:shipping_address) %>

    +
    +
    + <% if can? :edit, @order.user %> + <%= f.fields_for :ship_address do |sa_form| %> +
    + + <%= check_box_tag 'order[use_billing]', '1', @order.shipping_eq_billing_address? %> + <%= label_tag 'order[use_billing]', Spree.t(:use_billing_address) %> + +
    + + <%= render 'spree/admin/shared/address_form', f: sa_form, type: 'shipping' %> + <% end %> + <% else %> + <%= render 'spree/admin/shared/address', address: @order.ship_address %> + <% end %> +
    +
    +
    +
    + + <% if can? :edit, @order.user %> +
    + <%= button Spree.t('actions.update'), 'save' %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/orders/customer_details/edit.html.erb b/backend/app/views/spree/admin/orders/customer_details/edit.html.erb new file mode 100644 index 00000000000..72326be30f0 --- /dev/null +++ b/backend/app/views/spree/admin/orders/customer_details/edit.html.erb @@ -0,0 +1,29 @@ +<%= render 'spree/admin/shared/order_tabs', current: :customer_details %> + +<% content_for :page_title do %> + / <%= Spree.t(:customer_details) %> +<% end %> + +<% if can? :edit, @order.user %> +
    +
    +

    + <%= Spree.t(:customer_search) %> +

    +
    +
    + <%= hidden_field_tag :customer_search, nil, class: 'error-message' %> + <%= render partial: "spree/admin/orders/customer_details/autocomplete", formats: :js %> +
    +
    +<% end %> + +<%= render 'spree/admin/shared/error_messages', target: @order %> + +<%= form_for @order, url: spree.admin_order_customer_url(@order) do |f| %> + <%= render 'form', f: f %> +<% end %> + +
    + +<%= render 'spree/admin/shared/order_summary' %> diff --git a/backend/app/views/spree/admin/orders/edit.html.erb b/backend/app/views/spree/admin/orders/edit.html.erb new file mode 100644 index 00000000000..caf13de9b66 --- /dev/null +++ b/backend/app/views/spree/admin/orders/edit.html.erb @@ -0,0 +1,31 @@ +<%= render 'order_actions', order: @order, events: @order_events %> + +<%= render 'spree/admin/shared/order_tabs', current: :shipments %> + +<% content_for :page_title do %> + / <%= plural_resource_name(Spree::Shipment) %> +<% end %> + +
    + <%= render 'spree/admin/shared/error_messages', target: @order %> +
    + +<% if @order.payments.valid.any? && @order.considered_risky? %> + <%= render 'spree/admin/orders/risk_analysis', latest_payment: @order.payments.valid.last %> +<% end %> + +<%= render 'add_product' if @order.shipment_state != 'shipped' && can?(:update, @order) %> + +<% if @order.line_items.empty? %> +
    + <%= Spree.t(:your_order_is_empty_add_product)%> +
    +<% end %> + +
    +
    + <%= render partial: 'form', locals: { order: @order } %> +
    +
    + +<%= render 'spree/admin/shared/order_summary' %> diff --git a/backend/app/views/spree/admin/orders/index.html.erb b/backend/app/views/spree/admin/orders/index.html.erb new file mode 100644 index 00000000000..01f4fa49386 --- /dev/null +++ b/backend/app/views/spree/admin/orders/index.html.erb @@ -0,0 +1,240 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Order) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_order), new_admin_order_url, class: "btn-success", icon: 'add', id: 'admin_new_order' %> +<% end if can? :create, Spree::Order %> + +<% content_for :table_filter do %> +
    + + <%= search_form_for [:admin, @search] do |f| %> +
    +
    +
    + <%= label_tag :q_created_at_gt, Spree.t(:date_range) %> +
    +
    +
    + <%= f.text_field :created_at_gt, class: 'datepicker datepicker-from form-control js-filterable', value: params[:q][:created_at_gt], placeholder: Spree.t(:start) %> + + + +
    + +
    +
    +
    + <%= f.text_field :created_at_lt, class: 'datepicker datepicker-to form-control js-filterable', value: params[:q][:created_at_lt], placeholder: Spree.t(:stop) %> + + + +
    +
    +
    +
    +
    + +
    +
    + <%= label_tag :q_number_cont, Spree.t(:order_number, number: '') %> + <%= f.text_field :number_cont, class: 'form-control js-quick-search-target js-filterable' %> +
    +
    + +
    + +
    + +
    +
    + <%= label_tag :q_state_eq, Spree.t(:status) %> + <%= f.select :state_eq, Spree::Order.state_machines[:state].states.map {|s| [Spree.t("order_state.#{s.name}"), s.value]}, { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_payment_state_eq, Spree.t(:payment_state) %> + <%= f.select :payment_state_eq, Spree::Order::PAYMENT_STATES.map {|s| [Spree.t("payment_states.#{s}"), s]}, { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_shipment_state_eq, Spree.t(:shipment_state) %> + <%= f.select :shipment_state_eq, Spree::Order::SHIPMENT_STATES.map {|s| [Spree.t("shipment_states.#{s}"), s]}, { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    + +
    + +
    +
    + <%= label_tag :q_bill_address_firstname_start, Spree.t(:first_name_begins_with) %> + <%= f.text_field :bill_address_firstname_start, class: 'form-control js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_bill_address_lastname_start, Spree.t(:last_name_begins_with) %> + <%= f.text_field :bill_address_lastname_start, class: 'form-control js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_email_cont, Spree.t(:email) %> + <%= f.text_field :email_cont, class: 'form-control js-filterable' %> +
    +
    + +
    + +
    + +
    +
    + <%= label_tag :q_line_items_variant_sku_eq, Spree.t(:sku) %> + <%= f.text_field :line_items_variant_sku_eq, class: 'form-control js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_promotions_id_in, Spree.t(:promotion) %> + <%= f.select :promotions_id_in, Spree::Promotion.applied.pluck(:name, :id), { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_store_id_in, Spree.t(:store) %> + <%= f.select :store_id_in, Spree::Store.order("#{Spree::Store.table_name}.name").pluck(:name, :id), { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    +
    + <%= label_tag :q_channel_eq, Spree.t(:channel) %> + <%= f.select :channel_eq, Spree::Order.distinct.pluck(:channel), { include_blank: true }, class: 'select2 js-filterable' %> +
    +
    + +
    + +
    + +
    + <%= label_tag 'q_completed_at_not_null' do %> + <%= f.check_box :completed_at_not_null, {checked: @show_only_completed}, '1', '0' %> + <%= Spree.t(:show_only_complete_orders) %> + <% end %> +
    + +
    + <%= label_tag 'q_considered_risky_eq' do %> + <%= f.check_box :considered_risky_eq, {checked: (params[:q][:considered_risky_eq] == '1')}, '1', '' %> + <%= Spree.t(:show_only_considered_risky) %> + <% end %> +
    + +
    + +
    + +
    + +
    + <%= button Spree.t(:filter_results), 'search' %> +
    + + <% end %> + +
    + +<% end %> + +<%= render 'spree/admin/shared/index_table_options', collection: @orders %> + +<% if @orders.any? %> + + + + <% if @show_only_completed %> + + <% else %> + + <% end %> + + + + + <% if Spree::Order.checkout_step_names.include?(:delivery) %> + + <% end %> + + + + + + + <% @orders.each do |order| %> + + + + + + + <% if Spree::Order.checkout_step_names.include?(:delivery) %> + + <% end %> + + + + + <% end %> + +
    <%= sort_link @search, :completed_at, I18n.t(:completed_at, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :created_at, I18n.t(:created_at, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :number, I18n.t(:number, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :considered_risky, I18n.t(:considered_risky, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :state, I18n.t(:state, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :payment_state, I18n.t(:payment_state, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :shipment_state, I18n.t(:shipment_state, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :email, I18n.t(:email, scope: 'activerecord.attributes.spree/order') %><%= sort_link @search, :total, I18n.t(:total, scope: 'activerecord.attributes.spree/order') %>
    + <%= order_time(@show_only_completed ? order.completed_at : order.created_at) %> + <%= link_to order.number, edit_admin_order_path(order) %> + + <%= order.considered_risky ? Spree.t("risky") : Spree.t("safe") %> + + + <%= Spree.t("order_state.#{order.state.downcase}") %> + + + <% if order.payment_state %> + <%= link_to Spree.t("payment_states.#{order.payment_state}"), admin_order_payments_path(order) %> + + <% end %> + + <% if order.shipment_state %> + <%= Spree.t("shipment_states.#{order.shipment_state}") %> + + <% end %> + + <% if order.user %> + <%= link_to order.email, edit_admin_user_path(order.user) %> + <% else %> + <%= mail_to order.email %> + <% end %> + <% if order.user || order.email %> + + <% end %> + <%= order.display_total.to_html %> + <%= link_to_edit_url edit_admin_order_path(order), title: "admin_edit_#{dom_id(order)}", no_text: true if can?(:edit, order) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Order)) %>, + <%= link_to(Spree.t(:add_one), new_admin_order_url) if can? :create, Spree::Order %>! +
    +<% end %> + +<%= render 'spree/admin/shared/index_table_options', collection: @orders, simple: true %> diff --git a/backend/app/views/spree/admin/orders/store.html.erb b/backend/app/views/spree/admin/orders/store.html.erb new file mode 100644 index 00000000000..374f2eb0081 --- /dev/null +++ b/backend/app/views/spree/admin/orders/store.html.erb @@ -0,0 +1,7 @@ +<%= render 'spree/admin/shared/order_tabs', current: :store %> + +
    + <%= render 'spree/admin/shared/error_messages', target: @order %> +
    + +<%= render 'store_form', order: @order if can?(:update, @order) %> diff --git a/backend/app/views/spree/admin/payment_methods/_form.html.erb b/backend/app/views/spree/admin/payment_methods/_form.html.erb new file mode 100644 index 00000000000..666c8fe7de0 --- /dev/null +++ b/backend/app/views/spree/admin/payment_methods/_form.html.erb @@ -0,0 +1,56 @@ +
    + +
    + +
    +
    + <%= f.label :type, Spree.t(:provider) %> + <%= collection_select(:payment_method, :type, @providers, :to_s, :name, {}, {id: 'gtwy-type', class: 'select2'}) %> + + <% unless @object.new_record? %> + <%= preference_fields(@object, f) %> + + <% if @object.respond_to?(:preferences) %> +
    <%= Spree.t(:provider_settings_warning) %>
    + <% end %> + <% end %> +
    +
    + <%= label_tag :payment_method_display_on, Spree.t(:display) %> + <%= select(:payment_method, :display_on, Spree::PaymentMethod::DISPLAY.collect { |display| [Spree.t(display), display.to_s] }, {}, {class: 'select2'}) %> +
    +
    + <%= label_tag :payment_method_auto_capture, Spree.t(:auto_capture) %> + <%= select(:payment_method, :auto_capture, [["#{Spree.t(:use_app_default)} (#{Spree::Config[:auto_capture]})", ''], [Spree.t(:say_yes), true], [Spree.t(:say_no), false]], {}, {class: 'select2'}) %> +
    +
    + <%= Spree.t(:active) %> +
    + <%= label_tag :payment_method_active_true do %> + <%= radio_button :payment_method, :active, true %> + <%= Spree.t(:say_yes) %> + <% end %> +
    + +
    + <%= label_tag :payment_method_active_false do %> + <%= radio_button :payment_method, :active, false %> + <%= Spree.t(:say_no) %> + <% end %> +
    +
    +
    + +
    + <%= field_container :payment_method, :name, class: ['form-group'], 'data-hook' => 'name' do %> + <%= label_tag :payment_method_name, Spree.t(:name) %> + <%= text_field :payment_method, :name, class: 'form-control' %> + <%= error_message_on :payment_method, :name %> + <% end %> + <%= field_container :payment_method, :description, class: ['form-group'], 'data-hook' => 'description' do %> + <%= label_tag :payment_method_description, Spree.t(:description) %> + <%= text_area :payment_method, :description, { cols: 60, rows: 6, class: 'form-control' } %> + <% end %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/payment_methods/edit.html.erb b/backend/app/views/spree/admin/payment_methods/edit.html.erb new file mode 100644 index 00000000000..bd72bd9bb57 --- /dev/null +++ b/backend/app/views/spree/admin/payment_methods/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:payment_methods), spree.admin_payment_methods_url %> / + <%= @payment_method.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @payment_method } %> + +<%= form_for @payment_method, url: admin_payment_method_path(@payment_method) do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/payment_methods/index.html.erb b/backend/app/views/spree/admin/payment_methods/index.html.erb new file mode 100644 index 00000000000..90c58f204dc --- /dev/null +++ b/backend/app/views/spree/admin/payment_methods/index.html.erb @@ -0,0 +1,46 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::PaymentMethod) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_payment_method), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_payment_methods_link' %> +<% end if can? :create, Spree::PaymentMethod %> + +<% if @payment_methods.any? %> + + + + + + + + + + + + + <% @payment_methods.each do |method|%> + + + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:provider) %><%= Spree.t(:display) %><%= Spree.t(:active) %>
    + <% if can?(:edit, method) %> + + <% end %> + <%= method.name %><%= method.type %><%= Spree.t(method.display_on) %><%= method.active ? Spree.t(:say_yes) : Spree.t(:say_no) %> + <%= link_to_edit(method, no_text: true) if can? :edit, method %> + <%= link_to_delete(method, no_text: true) if can? :delete, method %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::PaymentMethod)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::PaymentMethod %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/payment_methods/new.html.erb b/backend/app/views/spree/admin/payment_methods/new.html.erb new file mode 100644 index 00000000000..59f23982e59 --- /dev/null +++ b/backend/app/views/spree/admin/payment_methods/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:payment_methods), spree.admin_payment_methods_url %> / + <%= Spree.t(:new_payment_method) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @payment_method } %> + +<%= form_for @payment_method, url: collection_url do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/payments/_capture_events.html.erb b/backend/app/views/spree/admin/payments/_capture_events.html.erb new file mode 100644 index 00000000000..d63a064febb --- /dev/null +++ b/backend/app/views/spree/admin/payments/_capture_events.html.erb @@ -0,0 +1,19 @@ +<% if @payment.capture_events.exists? %> +

    <%= Spree.t(:capture_events) %>

    + + + + + + + + + <% @payment.capture_events.each do |capture_event| %> + + + + + <% end %> + +
    <%= "#{Spree.t('date')}/#{Spree.t('time')}" %><%= Spree.t(:amount) %>
    <%= pretty_time(capture_event.created_at) %><%= capture_event.display_amount %>
    +<% end %> \ No newline at end of file diff --git a/backend/app/views/spree/admin/payments/_form.html.erb b/backend/app/views/spree/admin/payments/_form.html.erb new file mode 100644 index 00000000000..a39d3c691ca --- /dev/null +++ b/backend/app/views/spree/admin/payments/_form.html.erb @@ -0,0 +1,33 @@ +
    +
    + <%= f.label :amount, Spree.t(:amount) %> + <%= f.text_field :amount, value: @order.display_outstanding_balance.money, class: 'form-control' %> +
    +
    + <%= Spree.t(:payment_method) %> + <% @payment_methods.each do |method| %> +
    + +
    + <% end %> + +
    + <% @payment_methods.each do |method| %> + +
    + <% if method.source_required? %> + <%=render partial: "spree/admin/payments/source_forms/#{method.method_type}", + locals: { + payment_method: method, + previous_cards: method.reusable_sources(@order) + } + %> + <% end %> +
    + <% end %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/payments/_list.html.erb b/backend/app/views/spree/admin/payments/_list.html.erb new file mode 100644 index 00000000000..fe828daaf54 --- /dev/null +++ b/backend/app/views/spree/admin/payments/_list.html.erb @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + <% payments.each do |payment| %> + + + + + + + + + + <% end %> + +
    <%= Spree::Payment.human_attribute_name(:number) %><%= "#{Spree.t('date')}/#{Spree.t('time')}" %><%= Spree.t(:amount) %><%= Spree.t(:payment_method) %><%= Spree.t(:transaction_id) %><%= Spree.t(:payment_state) %>
    <%= link_to payment.number, spree.admin_order_payment_path(@order, payment) %><%= pretty_time(payment.created_at) %><%= payment.display_amount %><%= payment_method_name(payment) %><%= payment.transaction_id %> + + <%= Spree.t(payment.state, scope: :payment_states, default: payment.state.capitalize) %> + + + <% payment.actions.each do |action| %> + <% if action == 'credit' %> + <%= link_to_with_icon('refund', Spree.t(:refund), new_admin_order_payment_refund_path(@order, payment), no_text: true, class: "btn btn-default btn-sm") if can?(:create, Spree::Refund) %> + <% else %> + <%= link_to_with_icon(action, Spree.t(action), fire_admin_order_payment_path(@order, payment, e: action), method: :put, no_text: true, data: { action: action }, class: "btn btn-default btn-sm") if can?(action.to_sym, payment) %> + <% end %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/payments/credit.html.erb b/backend/app/views/spree/admin/payments/credit.html.erb new file mode 100644 index 00000000000..a9863a5c607 --- /dev/null +++ b/backend/app/views/spree/admin/payments/credit.html.erb @@ -0,0 +1,18 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :credit_cards } %> + +<% content_for :page_title do %> + / <%= Spree.t(:refund) %> +<% end %> + +<%= form_tag do %> +

    <%= Spree.t(:refund) %>

    +
    +

    + <%= label_tag :amount, Spree.t(:amount) %> + <%= text_field_tag :amount, @payment.amount %> +

    +

    + <%= button Spree.t(:make_refund) %> +

    +
    +<% end %> diff --git a/backend/app/views/spree/admin/payments/index.html.erb b/backend/app/views/spree/admin/payments/index.html.erb new file mode 100644 index 00000000000..607f8553e9b --- /dev/null +++ b/backend/app/views/spree/admin/payments/index.html.erb @@ -0,0 +1,38 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :payments } %> + +<% content_for :page_actions do %> + <% if @order.outstanding_balance? && can?(:create, Spree::Payment) %> + + <%= button_link_to Spree.t(:new_payment), new_admin_order_payment_url(@order), class: "btn-success", icon: 'add' %> + + <% end %> +<% end %> + +<% content_for :page_title do %> + / <%= Spree.t(:payments) %> +<% end %> + +<% if @order.outstanding_balance? %> +
    + <%= @order.outstanding_balance < 0 ? Spree.t(:credit_owed) : Spree.t(:balance_due) %>: <%= @order.display_outstanding_balance %> +
    +<% end %> + +<% if @payments.any? %> + +
    + <%= render partial: 'list', locals: { payments: @payments } %> +
    + + <% if @refunds.any? %> +
    + <%= Spree.t(:refunds) %> + <%= render partial: 'spree/admin/shared/refunds', locals: { refunds: @refunds, show_actions: true } %> +
    + <% end %> + +<% else %> +
    <%= Spree.t(:order_has_no_payments) %>
    +<% end %> + +<%= render partial: 'spree/admin/shared/order_summary' %> diff --git a/backend/app/views/spree/admin/payments/new.html.erb b/backend/app/views/spree/admin/payments/new.html.erb new file mode 100644 index 00000000000..99468068a72 --- /dev/null +++ b/backend/app/views/spree/admin/payments/new.html.erb @@ -0,0 +1,25 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :payments } %> + +<% content_for :page_title do %> + / <%= Spree.t(:new_payment) %> +<% end %> + +<% if @payment_methods.any? %> + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @payment } %> + + <%= form_for @payment, url: admin_order_payments_path(@order) do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button @order.cart? ? Spree.t('actions.continue') : Spree.t('actions.update'), @order.cart? ? 'arrow-right' : 'save' %> +
    +
    + <% end %> + +<% else %> +
    + <%= Spree.t(:cannot_create_payment_without_payment_methods) %> + <%= link_to Spree.t(:please_define_payment_methods), spree.admin_payment_methods_url %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/payments/show.html.erb b/backend/app/views/spree/admin/payments/show.html.erb new file mode 100644 index 00000000000..d4d7437ab65 --- /dev/null +++ b/backend/app/views/spree/admin/payments/show.html.erb @@ -0,0 +1,28 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :payments } %> + +<% content_for :page_title do %> + / + <%= I18n.t(:one, scope: "activerecord.models.spree/payment") %> + / + <%= payment_method_name(@payment) %> + / + + <%= Spree.t(@payment.state, scope: :payment_states, default: @payment.state.capitalize) %> + +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:logs), spree.admin_order_payment_log_entries_url(@order, @payment), icon: 'file' %> +<% end %> + +<%= render partial: "spree/admin/payments/source_views/#{@payment.payment_method.method_type}", + locals: { + payment: @payment.source.is_a?(Spree::Payment) ? @payment.source : @payment + } +%> + +
    + <%= Spree.t(:amount) %>: <%= @payment.display_amount.to_html %> +
    + +<%= render 'spree/admin/payments/capture_events' %> diff --git a/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb b/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb new file mode 100644 index 00000000000..197f59f531f --- /dev/null +++ b/backend/app/views/spree/admin/payments/source_forms/_gateway.html.erb @@ -0,0 +1,56 @@ +
    +
    +
      + <% if previous_cards.any? %> + <% previous_cards.each do |card| %> +
    • + +
    • + <% end %> +
    • + +
    • + <% end %> +
    +
    + +
    + <% param_prefix = "payment_source[#{payment_method.id}]" %> + +
    + <%= hidden_field_tag "#{param_prefix}[cc_type]", '', {class: 'ccType'} %> + <%= label_tag "card_number#{payment_method.id}", raw(Spree.t(:card_number) + content_tag(:span, ' *', class: 'required')) %> + <%= text_field_tag "#{param_prefix}[number]", '', class: 'required form-control cardNumber', id: "card_number#{payment_method.id}", maxlength: 19 %> + +
    + +
    + <%= label_tag "card_name#{payment_method.id}", raw(Spree.t(:name) + content_tag(:span, ' *', class: 'required')) %> + <%= text_field_tag "#{param_prefix}[name]", '', id: "card_name#{payment_method.id}", class: 'required form-control', maxlength: 19 %> +
    + +
    + <%= label_tag "card_expiry#{payment_method.id}", raw(Spree.t(:expiration) + content_tag(:span, ' *', class: 'required')) %>
    + <%= text_field_tag "#{param_prefix}[expiry]", '', id: "card_expiry#{payment_method.id}", class: "required cardExpiry form-control", placeholder: "MM / YY" %> +
    + +
    + <%= label_tag "card_code#{payment_method.id}", raw(Spree.t(:card_code) + content_tag(:span, ' *', class: "required")) %> + <%= text_field_tag "#{param_prefix}[verification_value]", '', id: "card_code#{payment_method.id}", class: 'required form-control cardCode', size: 5 %> + + (<%= Spree.t(:what_is_this) %>) + +
    + + <%= image_tag 'credit_cards/credit_card.gif', class: 'credit-card-image' %> + +
    +
    diff --git a/backend/app/views/spree/admin/payments/source_forms/_storecredit.html.erb b/backend/app/views/spree/admin/payments/source_forms/_storecredit.html.erb new file mode 100644 index 00000000000..781fe757ebd --- /dev/null +++ b/backend/app/views/spree/admin/payments/source_forms/_storecredit.html.erb @@ -0,0 +1,9 @@ +<% if @order.could_use_store_credit? %> +
    +

    <%= Spree.t('admin.user.available_store_credit', amount: @order.display_total_available_store_credit) %>

    +
    +<% else %> +
    +

    <%= Spree.t('admin.user.no_store_credit') %>

    +
    +<% end %> diff --git a/vendor/plugins/acts_as_tree/test/fixtures/mixin.rb b/backend/app/views/spree/admin/payments/source_views/_check.html.erb similarity index 100% rename from vendor/plugins/acts_as_tree/test/fixtures/mixin.rb rename to backend/app/views/spree/admin/payments/source_views/_check.html.erb diff --git a/backend/app/views/spree/admin/payments/source_views/_gateway.html.erb b/backend/app/views/spree/admin/payments/source_views/_gateway.html.erb new file mode 100644 index 00000000000..767367fc0ff --- /dev/null +++ b/backend/app/views/spree/admin/payments/source_views/_gateway.html.erb @@ -0,0 +1,21 @@ +
    + <%= Spree.t(:credit_card) %> + + + + + + + + + + + + + + + + + +
    <%= Spree.t(:name_on_card) %>:<%= payment.source.name %>
    <%= Spree.t(:card_type) %>:<%= payment.source.cc_type %>
    <%= Spree.t(:card_number) %>:<%= payment.source.display_number %>
    <%= Spree.t(:expiration) %>:<%= payment.source.month %>/<%= payment.source.year %>
    +
    diff --git a/backend/app/views/spree/admin/payments/source_views/_storecredit.html.erb b/backend/app/views/spree/admin/payments/source_views/_storecredit.html.erb new file mode 100644 index 00000000000..6fba8666718 --- /dev/null +++ b/backend/app/views/spree/admin/payments/source_views/_storecredit.html.erb @@ -0,0 +1,29 @@ +
    + <%= Spree.t(:store_credit_name) %> + + + + + + + + + + + + + + + + + + + + + + + + + +
    <%= Spree.t(:used) %>:<%= payment.source.display_amount_used %>
    <%= Spree.t(:amount) %>:<%= payment.source.display_amount %>
    <%= Spree.t(:memo) %>:<%= payment.source.memo %>
    <%= Spree.t(:type) %>:<%= payment.source.category_name %>
    <%= Spree.t(:created_by) %>:<%= payment.source.created_by_email %>
    <%= Spree.t(:issued_on) %>:<%= pretty_time(payment.source.created_at) %>
    +
    diff --git a/backend/app/views/spree/admin/product_properties/_product_property_fields.html.erb b/backend/app/views/spree/admin/product_properties/_product_property_fields.html.erb new file mode 100644 index 00000000000..ee035e3c19f --- /dev/null +++ b/backend/app/views/spree/admin/product_properties/_product_property_fields.html.erb @@ -0,0 +1,19 @@ + + + <% if f.object.persisted? && can?(:edit, f.object) %> + + <%= f.hidden_field :id %> + <% end %> + + + <%= f.text_field :property_name, class: 'autocomplete form-control' %> + + + <%= f.text_field :value, class: 'form-control' %> + + + <% if f.object.persisted? && can?(:destroy, f.object) %> + <%= link_to_delete f.object, no_text: true %> + <% end %> + + diff --git a/backend/app/views/spree/admin/product_properties/index.html.erb b/backend/app/views/spree/admin/product_properties/index.html.erb new file mode 100644 index 00000000000..b2cd5fe3b18 --- /dev/null +++ b/backend/app/views/spree/admin/product_properties/index.html.erb @@ -0,0 +1,43 @@ +<%= render 'spree/admin/shared/product_tabs', current: :properties %> +<%= render 'spree/admin/shared/error_messages', target: @product %> + +<% content_for :page_actions do %> + <%= button_link_to(Spree.t(:add_product_properties), "javascript:;", { icon: 'add', :'data-target' => "tbody#product_properties", class: 'btn-success spree_add_fields' }) %> + <%= button_link_to Spree.t(:select_from_prototype), available_admin_prototypes_url, { icon: 'properties', remote: true, 'data-update' => 'prototypes', class: 'btn-default' } %> +<% end if can? :create, Spree::ProductProperty %> + +<%= form_for @product, url: spree.admin_product_url(@product), method: :put do |f| %> +
    +
    + + + + + + + + + + + <%= f.fields_for :product_properties do |pp_form| %> + <%= render 'product_property_fields', f: pp_form %> + <% end %> + +
    <%= Spree.t(:property) %><%= Spree.t(:value) %>
    + + <%= render('spree/admin/shared/edit_resource_links') if can? :update, Spree::ProductProperty %> + + <%= hidden_field_tag 'clear_product_properties', 'true' %> +
    +<% end %> + +<%= javascript_tag do %> + var properties = <%= raw(@properties.to_json) %>; + $('#product_properties').on('keydown', 'input.autocomplete', function() { + already_auto_completed = $(this).is('ac_input'); + if (!already_auto_completed) { + $(this).autocomplete({source: properties}); + $(this).focus(); + } + }); +<% end %> diff --git a/backend/app/views/spree/admin/products/_add_stock_form.html.erb b/backend/app/views/spree/admin/products/_add_stock_form.html.erb new file mode 100644 index 00000000000..60db4b5fdbc --- /dev/null +++ b/backend/app/views/spree/admin/products/_add_stock_form.html.erb @@ -0,0 +1,42 @@ +
    +

    + <%= Spree.t(:add_stock) %> +

    +
    +
    + <%= form_for [:admin, Spree::StockMovement.new], url: admin_stock_items_path do |f| %> +
    +
    +
    + <%= f.field_container :quantity do %> + <%= f.label :quantity, Spree.t(:quantity) %> + <%= f.number_field :quantity, class: 'form-control', value: 1 %> + <% end %> +
    +
    +
    +
    + <%= f.field_container :stock_location do %> + <%= label_tag :stock_location_id, Spree.t(:stock_location) %> + <%= select_tag 'stock_location_id', options_from_collection_for_select(@stock_locations, :id, :name), + class: 'select2' %> + <% end %> +
    +
    + +
    +
    + <%= f.field_container :variant_id do %> + <%= label_tag 'variant_id', Spree.t(:variant) %> + <%= select_tag 'variant_id', options_from_collection_for_select(@variants, :id, :sku_and_options_text), + class: 'select2' %> + <% end %> +
    +
    +
    + +
    + <%= button Spree.t(:add_stock), 'plus' %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/products/_autocomplete.js.erb b/backend/app/views/spree/admin/products/_autocomplete.js.erb new file mode 100644 index 00000000000..5dc62d2ffc6 --- /dev/null +++ b/backend/app/views/spree/admin/products/_autocomplete.js.erb @@ -0,0 +1,25 @@ + diff --git a/backend/app/views/spree/admin/products/_form.html.erb b/backend/app/views/spree/admin/products/_form.html.erb new file mode 100644 index 00000000000..0c6c02d3dc3 --- /dev/null +++ b/backend/app/views/spree/admin/products/_form.html.erb @@ -0,0 +1,230 @@ + +
    + +
    + +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, raw(Spree.t(:name) + content_tag(:span, ' *', class: 'required')) %> + <%= f.text_field :name, class: 'form-control title' %> + <%= f.error_message_on :name %> + <% end %> +
    + +
    + <%= f.field_container :slug, class: ['form-group'] do %> + <%= f.label :slug, raw(Spree.t(:slug) + content_tag(:span, ' *', class: "required")) %> + <%= f.text_field :slug, class: 'form-control title' %> + <%= f.error_message_on :slug %> + <% end %> +
    + +
    + <%= f.field_container :description, class: ['form-group'] do %> + <%= f.label :description, Spree.t(:description) %> + <%= f.text_area :description, { rows: "#{unless @product.has_variants? then '20' else '13' end}", class: 'form-control' } %> + <%= f.error_message_on :description %> + <% end %> +
    +
    + +
    +
    + <%= f.field_container :price, class: ['form-group'] do %> + <%= f.label :price, raw(Spree.t(:master_price) + content_tag(:span, ' *', class: "required")) %> + <%= f.text_field :price, value: number_to_currency(@product.price, unit: ''), class: 'form-control', disabled: (cannot? :update, @product.price) %> + <%= f.error_message_on :price %> + <% end %> +
    + +
    + <%= f.field_container :cost_price, class: ['form-group'] do %> + <%= f.label :cost_price, Spree.t(:cost_price) %> + <%= f.text_field :cost_price, value: number_to_currency(@product.cost_price, unit: ''), class: 'form-control' %> + <%= f.error_message_on :cost_price %> + <% end %> +
    +
    + <%= f.field_container :cost_currency, class: ['form-group'] do %> + <%= f.label :cost_currency, Spree.t(:cost_currency) %> + <%= f.text_field :cost_currency, class: 'form-control' %> + <%= f.error_message_on :cost_currency %> + <% end %> +
    + +
    + <%= f.field_container :available_on, class: ['form-group'] do %> + <%= f.label :available_on, Spree.t(:available_on) %> + <%= f.error_message_on :available_on %> + <%= f.text_field :available_on, value: datepicker_field_value(@product.available_on), class: 'datepicker form-control' %> + <% end %> +
    + +
    + <%= f.field_container :discontinue_on, class: ['form-group'] do %> + <%= f.label :discontinue_on, Spree.t(:discontinue_on) %> + <%= f.error_message_on :discontinue_on %> + <%= f.text_field :discontinue_on, value: datepicker_field_value(@product.discontinue_on), class: 'datepicker form-control' %> + <% end %> +
    + +
    + <%= f.field_container :promotionable, class: ['form-group'] do %> + <%= f.label :promotionable, Spree.t(:promotionable) %> + <%= f.error_message_on :promotionable %> + <%= f.check_box :promotionable, class: 'form-control' %> + <% end %> +
    + +
    + <%= f.field_container :master_sku, class: ['form-group'] do %> + <%= f.label :master_sku, Spree.t(:master_sku) %> + <%= f.text_field :sku, size: 16, class: 'form-control' %> + <% end %> +
    + + <% if @product.has_variants? %> +
    + <%= f.label :skus, Spree.t(:sku).pluralize %> +
    + <%= Spree.t(:info_product_has_multiple_skus, count: @product.variants.size) %> +
      + <% @product.variants.first(5).each do |variant| %> +
    • <%= variant.sku %>
    • + <% end %> +
    + <% if @product.variants.size > 5 %> + + <%= Spree.t(:info_number_of_skus_not_shown, count: @product.variants.size - 5) %> + + <% end %> +
    +
    + <% if can?(:admin, Spree::Variant) %> + <%= link_to_with_icon 'variants', Spree.t(:manage_variants), spree.admin_product_variants_url(@product), class: "btn btn-default" %> + <% end %> +
    +
    + <% else %> +
    +
    +
    + <%= f.label :weight, Spree.t(:weight) %> + <%= f.text_field :weight, value: number_with_precision(@product.weight, precision: 2), size: 4, class: 'form-control' %> +
    +
    + +
    +
    + <%= f.label :height, Spree.t(:height) %> + <%= f.text_field :height, value: number_with_precision(@product.height, precision: 2), size: 4, class: 'form-control' %> +
    +
    + +
    +
    + <%= f.label :width, Spree.t(:width) %> + <%= f.text_field :width, value: number_with_precision(@product.width, precision: 2), size: 4, class: 'form-control' %> +
    +
    + +
    +
    + <%= f.label :depth, Spree.t(:depth) %> + <%= f.text_field :depth, value: number_with_precision(@product.depth, precision: 2), size: 4, class: 'form-control' %> +
    +
    +
    + <% end %> + +
    + <%= f.field_container :shipping_category, class: ['form-group'] do %> + <%= f.label :shipping_category_id, Spree.t(:shipping_category) %> + <%= f.collection_select(:shipping_category_id, @shipping_categories, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> + <%= f.error_message_on :shipping_category %> + <% end %> +
    + +
    + <%= f.field_container :tax_category, class: ['form-group'] do %> + <%= f.label :tax_category_id, Spree.t(:tax_category) %> + <%= f.collection_select(:tax_category_id, @tax_categories, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> + <%= f.error_message_on :tax_category %> + <% end %> +
    +
    + +
    + +
    + <%= f.field_container :taxons, class: ['form-group'] do %> + <%= f.label :taxon_ids, Spree.t(:taxons) %> + + <% if can? :modify, Spree::Classification %> + <%= f.hidden_field :taxon_ids, value: @product.taxon_ids.join(',') %> + <% elsif @product.taxons.any? %> +
      + <% @product.taxons.each do |taxon| %> +
    • <%= taxon.name %>
    • + <% end %> +
    + <% else %> +
    <%= Spree.t(:no_resource_found, resource: :taxons) %>
    + <% end %> + + <% end %> +
    + +
    + <%= f.field_container :option_types, class: ['form-group'] do %> + <%= f.label :option_type_ids, Spree.t(:option_types) %> + + <% if can? :modify, Spree::ProductOptionType %> + <%= f.hidden_field :option_type_ids, value: @product.option_type_ids.join(',') %> + <% elsif @product.option_types.any? %> +
      + <% @product.option_types.each do |type| %> +
    • <%= type.presentation %> (<%= type.name %>)
    • + <% end %> +
    + <% else %> +
    <%= Spree.t(:no_resource_found, resource: :option_types) %>
    + <% end %> + + <% end %> +
    + +
    + <%= f.field_container :tag_list, class: ['form-group'] do %> + <%= f.label :tag_list, Spree.t(:tags) %> + <%= f.hidden_field :tag_list, value: @product.tag_list.join(','), class: 'tag_picker' %> + <% end %> +
    + +
    +
    + <%= f.field_container :meta_title, class: ['form-group'] do %> + <%= f.label :meta_title, Spree.t(:meta_title) %> + <%= f.text_field :meta_title, class: 'form-control' %> + <% end %> +
    + +
    + <%= f.field_container :meta_keywords, class: ['form-group'] do %> + <%= f.label :meta_keywords, Spree.t(:meta_keywords) %> + <%= f.text_field :meta_keywords, class: 'form-control' %> + <% end %> +
    + +
    + <%= f.field_container :meta_description, class: ['form-group'] do %> + <%= f.label :meta_description, Spree.t(:meta_description) %> + <%= f.text_field :meta_description, class: 'form-control' %> + <% end %> +
    + +
    + +
    +
    diff --git a/backend/app/views/spree/admin/products/edit.html.erb b/backend/app/views/spree/admin/products/edit.html.erb new file mode 100644 index 00000000000..7ce5b0f9b3c --- /dev/null +++ b/backend/app/views/spree/admin/products/edit.html.erb @@ -0,0 +1,18 @@ +<% content_for :page_actions do %> + <% if frontend_available? %> + <%= button_link_to Spree.t(:preview_product), product_url(@product), { class: "btn-default", icon: 'eye-open', id: 'admin_preview_product', target: :_blank } %> + <% end %> + <% if can?(:create, Spree::Product) %> + <%= button_link_to Spree.t(:new_product), new_object_url, { class: "btn-success", icon: 'add', id: 'admin_new_product' } %> + <% end %> +<% end %> + +<%= render partial: 'spree/admin/shared/product_tabs', locals: {current: :details} %> +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @product } %> + +<%= form_for [:admin, @product], method: :put, html: { multipart: true } do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/products/index.html.erb b/backend/app/views/spree/admin/products/index.html.erb new file mode 100644 index 00000000000..6fdb02b780f --- /dev/null +++ b/backend/app/views/spree/admin/products/index.html.erb @@ -0,0 +1,91 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Product) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_product), new_object_url, { class: "btn-success", icon: 'add', id: 'admin_new_product' } %> +<% end if can?(:create, Spree::Product) %> + +<% content_for :table_filter do %> +
    + + <%= search_form_for [:admin, @search] do |f| %> + <%- locals = {f: f} %> +
    +
    +
    + <%= f.label :name_cont, Spree.t(:name) %> + <%= f.text_field :name_cont, size: 15, class: "form-control js-quick-search-target js-filterable" %> +
    +
    +
    +
    + <%= f.label :variants_including_master_sku_cont, Spree.t(:sku) %> + <%= f.text_field :variants_including_master_sku_cont, size: 15, class: "form-control js-filterable" %> +
    +
    +
    +
    + +
    + +
    + +
    +
    +
    +
    + <%= button Spree.t(:search), 'search' %> +
    + <% end %> + +
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection } %> + +<% if @collection.any? %> + + + + + + + + + + + + + <% @collection.each do |product| %> + id="<%= spree_dom_id product %>" data-hook="admin_products_index_rows" class="<%= cycle('odd', 'even') %>"> + + + + + + + + <% end %> + +
    <%= Spree.t(:sku) %><%= Spree.t(:status) %><%= sort_link @search,:name, Spree.t(:name), { default_order: "desc" }, {title: 'admin_products_listing_name_title'} %> + <%= sort_link @search, :master_default_price_amount, Spree.t(:master_price), {}, {title: 'admin_products_listing_price_title'} %> +
    <%= product.sku rescue '' %><%= available_status(product) %> <%= mini_image product %><%= link_to product.try(:name), edit_admin_product_path(product) %><%= product.display_price.to_html rescue '' %> + <%= link_to_edit product, no_text: true, class: 'edit' if can?(:edit, product) && !product.deleted? %> + <%= link_to_clone product, no_text: true, class: 'clone' if can?(:clone, product) %> + <%= link_to_delete product, no_text: true if can?(:delete, product) && !product.deleted? %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Product)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::Product) %>! +
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection } %> diff --git a/backend/app/views/spree/admin/products/new.html.erb b/backend/app/views/spree/admin/products/new.html.erb new file mode 100644 index 00000000000..392cec6a371 --- /dev/null +++ b/backend/app/views/spree/admin/products/new.html.erb @@ -0,0 +1,89 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @product } %> + +<% content_for :page_title do %> + <%= link_to Spree.t(:products), spree.admin_products_url %> / + <%= Spree.t(:new_product) %> +<% end %> + +<%= form_for [:admin, @product], html: { multipart: true } do |f| %> +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control title', required: :required %> + <%= f.error_message_on :name %> + <% end %> + +
    + <% unless @product.has_variants? %> +
    + <%= f.field_container :sku, class: ['form-group'] do %> + <%= f.label :sku, Spree.t(:sku) %> + <%= f.text_field :sku, size: 16, class: 'form-control' %> + <%= f.error_message_on :sku %> + <% end %> +
    + <% end %> + +
    + <%= f.field_container :prototype, class: ['form-group'] do %> + <%= f.label :prototype_id, Spree.t(:prototype) %> + <%= f.collection_select :prototype_id, Spree::Prototype.all, :id, :name, {include_blank: true}, {class: 'select2'} %> + <% end %> +
    + +
    + <%= f.field_container :price, class: ['form-group'] do %> + <%= f.label :price, Spree.t(:master_price) %> * + <%= f.text_field :price, value: number_to_currency(@product.price, unit: ''), class: 'form-control', required: :required %> + <%= f.error_message_on :price %> + <% end %> +
    + +
    + <%= f.field_container :available_on, class: ['form-group'] do %> + <%= f.label :available_on, Spree.t(:available_on) %> + <%= f.error_message_on :available_on %> + <%= f.text_field :available_on, class: 'datepicker form-control' %> + <% end %> +
    + +
    + <%= f.field_container :shipping_category, class: ['form-group'] do %> + <%= f.label :shipping_category_id, Spree.t(:shipping_categories) %>* + <%= f.collection_select(:shipping_category_id, @shipping_categories, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2', required: :required }) %> + <%= f.error_message_on :shipping_category_id %> + <% end %> +
    + +
    + +
    + <%= render file: 'spree/admin/prototypes/show' if @prototype %> +
    + + <%= render partial: 'spree/admin/shared/new_resource_links' %> + +
    +<% end %> + + diff --git a/backend/app/views/spree/admin/products/stock.html.erb b/backend/app/views/spree/admin/products/stock.html.erb new file mode 100644 index 00000000000..cc9eed7238e --- /dev/null +++ b/backend/app/views/spree/admin/products/stock.html.erb @@ -0,0 +1,78 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: {current: :stock} %> +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @product } %> + +<% if can? :create, Spree::StockMovement %> +
    + <%= render 'add_stock_form' %> +
    +<% end %> + +
    + + + + + + + + + <% @variants.each do |variant| %> + <% if variant.stock_items.present? %> + + + + + + <% end %> + + <% end %> + +
    <%= Spree.t(:variant) %><%= Spree.t(:stock_location_info) %>
    + <% if variant.images.present? %> + <%= image_tag main_app.url_for(variant.images.first.url(:mini)) %> + <% end %> + + <%= variant.sku_and_options_text %> + <%= form_tag admin_product_variants_including_master_path(@product, variant, format: :js), method: :put, class: 'toggle_variant_track_inventory' do %> +
    + <%= label_tag "track_inventory_#{ variant.id }" do %> + <%= check_box_tag 'track_inventory', 1, variant.track_inventory?, + class: 'track_inventory_checkbox', id: "track_inventory_#{ variant.id }" %> + <%= Spree.t(:track_inventory) %> + <%= hidden_field_tag 'variant[track_inventory]', variant.track_inventory?, + class: 'variant_track_inventory', + id: "variant_track_inventory_#{variant.id}" %> + <% end %> +
    + <% end if can?(:update, @product) && can?(:update, variant) %> +
    + + + + + + + + + <% variant.stock_items.each do |item| %> + <% next unless @stock_locations.include?(item.stock_location) %> + + + + + + + <% end %> + +
    <%= Spree.t(:stock_location) %><%= Spree.t(:count_on_hand) %><%= Spree.t(:backorderable) %>
    <%= item.stock_location.name %><%= item.count_on_hand %> + <%= form_tag admin_stock_item_path(item), method: :put, class: 'toggle_stock_item_backorderable' do %> + <%= check_box_tag 'stock_item[backorderable]', true, + item.backorderable?, + class: 'stock_item_backorderable', + id: "stock_item_backorderable_#{item.stock_location.id}" %> + <% end if can? :update, item %> + + <%= link_to_with_icon('delete', Spree.t(:remove), [:admin, item], method: :delete, remote: true, class: 'icon_link btn btn-danger btn-sm', data: { action: :remove, confirm: Spree.t(:are_you_sure) }, no_text: true) if can? :destroy, item %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/promotion_actions/create.js.erb b/backend/app/views/spree/admin/promotion_actions/create.js.erb new file mode 100644 index 00000000000..058a5501dc0 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_actions/create.js.erb @@ -0,0 +1,12 @@ +$('#actions').append('<%= escape_javascript( render(partial: 'spree/admin/promotions/promotion_action', object: @promotion_action) ) %>'); +$('#actions .no-objects-found').hide(); +$(document).ready(function(){ + $(".variant_autocomplete").variantAutocomplete(); + //enable select2 functions for recently added box + $('.type-select.select2').last().select2(); +}); +initProductActions(); + + +$('#<%= dom_id @promotion_action %>').hide(); +$('#<%= dom_id @promotion_action %>').fadeIn(); diff --git a/backend/app/views/spree/admin/promotion_actions/destroy.js.erb b/backend/app/views/spree/admin/promotion_actions/destroy.js.erb new file mode 100644 index 00000000000..a6120db69ea --- /dev/null +++ b/backend/app/views/spree/admin/promotion_actions/destroy.js.erb @@ -0,0 +1 @@ +$('#<%= dom_id @promotion_action %>').fadeOut().remove(); diff --git a/backend/app/views/spree/admin/promotion_categories/_form.html.erb b/backend/app/views/spree/admin/promotion_categories/_form.html.erb new file mode 100644 index 00000000000..4459aba5e97 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_categories/_form.html.erb @@ -0,0 +1,11 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @promotion_category } %> + +<%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> +<% end %> +<%= f.field_container :code, class: ['form-group'] do %> + <%= f.label :code %> + <%= f.text_field :code, class: 'form-control' %> +<% end %> diff --git a/backend/app/views/spree/admin/promotion_categories/edit.html.erb b/backend/app/views/spree/admin/promotion_categories/edit.html.erb new file mode 100644 index 00000000000..88ac2065e89 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_categories/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:promotion_categories), spree.admin_promotion_categories_url %> / + <%= @promotion_category.name %> +<% end %> + +<%= form_for @promotion_category, url: object_url, method: :put do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotion_categories/index.html.erb b/backend/app/views/spree/admin/promotion_categories/index.html.erb new file mode 100644 index 00000000000..0989c0ba9a5 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_categories/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::PromotionCategory) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_promotion_category), new_object_url, icon: 'add', class: 'btn-success' %> +<% end if can?(:create, Spree::PromotionCategory) %> + +<% if @promotion_categories.any? %> + + + + + + + + <% @promotion_categories.each do |promotion_category| %> + + + + + + <% end %> + +
    <%= Spree::PromotionCategory.human_attribute_name :name %><%= Spree::PromotionCategory.human_attribute_name :code %>
    <%= promotion_category.name %><%= promotion_category.code %> + <%= link_to_edit promotion_category, no_text: true if can?(:edit, promotion_category) %> + <%= link_to_delete promotion_category, no_text: true if can?(:delete, promotion_category) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::PromotionCategory)) %>, + <%= link_to Spree.t(:add_one), new_object_url %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotion_categories/new.html.erb b/backend/app/views/spree/admin/promotion_categories/new.html.erb new file mode 100644 index 00000000000..56d6ce994de --- /dev/null +++ b/backend/app/views/spree/admin/promotion_categories/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to plural_resource_name(Spree::PromotionCategory), spree.admin_promotion_categories_url %> / + <%= Spree.t(:new_promotion_category) %> +<% end %> + +<%= form_for :promotion_category, url: collection_url do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotion_rules/create.js.erb b/backend/app/views/spree/admin/promotion_rules/create.js.erb new file mode 100644 index 00000000000..c33ce059f81 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_rules/create.js.erb @@ -0,0 +1,10 @@ +$('#rules').append('<%= escape_javascript( render(partial: 'spree/admin/promotions/promotion_rule', object: @promotion_rule) ) %>'); +$('#rules .no-objects-found').hide(); + +$('.product_picker').productAutocomplete(); +$('.user_picker').userAutocomplete(); + +$('#promotion_rule_type').html('<%= escape_javascript options_for_promotion_rule_types(@promotion) %>'); +$('#promotion_rule_type').select2(); + +set_taxon_select('#product_taxon_ids') diff --git a/backend/app/views/spree/admin/promotion_rules/destroy.js.erb b/backend/app/views/spree/admin/promotion_rules/destroy.js.erb new file mode 100644 index 00000000000..68d2b3f7e16 --- /dev/null +++ b/backend/app/views/spree/admin/promotion_rules/destroy.js.erb @@ -0,0 +1,3 @@ +$('#<%= dom_id @promotion_rule %>').fadeOut().remove(); + +$('#promotion_rule_type').html('<%= escape_javascript options_for_promotion_rule_types(@promotion) %>'); diff --git a/backend/app/views/spree/admin/promotions/_actions.html.erb b/backend/app/views/spree/admin/promotions/_actions.html.erb new file mode 100644 index 00000000000..7a86277c24a --- /dev/null +++ b/backend/app/views/spree/admin/promotions/_actions.html.erb @@ -0,0 +1,35 @@ +
    +

    + <%= Spree.t(:promotion_actions) %> +

    +
    +
    + <%= form_tag spree.admin_promotion_promotion_actions_path(@promotion), remote: true, id: 'new_promotion_action_form' do %> + <% options = options_for_select( Rails.application.config.spree.promotions.actions.map(&:name).map {|name| [ Spree.t("promotion_action_types.#{name.demodulize.underscore}.name"), name] } ) %> + +
    + <%= label_tag :action_type, Spree.t(:add_action_of_type)%> + <%= select_tag 'action_type', options, class: 'select2' %> +
    +
    + <%= button Spree.t(:add), 'plus', 'submit', class: "btn-success" %> +
    + + <% end %> + + <%= form_for @promotion, url: spree.admin_promotion_path(@promotion), method: :put do |f| %> +
    + <% if @promotion.actions.any? %> + <%= render partial: 'promotion_action', collection: @promotion.actions %> + <% else %> +
    + <%= Spree.t(:no_actions_added) %> +
    + <% end %> +
    +
    + <%= button Spree.t('actions.update'), 'save' %> +
    + <% end %> + +
    diff --git a/backend/app/views/spree/admin/promotions/_form.html.erb b/backend/app/views/spree/admin/promotions/_form.html.erb new file mode 100644 index 00000000000..c43714dbf78 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/_form.html.erb @@ -0,0 +1,67 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @promotion } %> + +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + + <%= f.field_container :code, class: ['form-group'] do %> + <%= f.label :code %> + <%= f.text_field :code, class: 'form-control' %> + <% end %> + + <%= f.field_container :generate_code, class: ['checkbox'] do %> + <%= f.label :generate_code do %> + <%= f.check_box :generate_code %> + <%= Spree.t(:generate_code) %> + <% end %> + <% end %> + + <%= f.field_container :path, class: ['form-group'] do %> + <%= f.label :path %> + <%= f.text_field :path, class: 'form-control' %> + <% end %> + + <%= f.field_container :advertise, class: ['checkbox'] do %> + <%= f.label :advertise do %> + <%= f.check_box :advertise %> + <%= Spree.t(:advertise) %> + <% end %> + <% end %> +
    + +
    + <%= f.field_container :description, class: ['form-group'] do %> + <%= f.label :description %> + <%= f.text_area :description, rows: 7, class: 'form-control' %> + <% end %> + + <%= f.field_container :category, class: ['form-group'] do %> + <%= f.label :promotion_category %> + <%= f.collection_select(:promotion_category_id, @promotion_categories, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> + <% end %> +
    + +
    + <%= f.field_container :usage_limit do %> + <%= f.label :usage_limit %> + <%= f.number_field :usage_limit, min: 0, class: 'form-control' %> +

    + <%= Spree.t(:current_promotion_usage, count: @promotion.credits_count) %> +

    + <% end %> + +
    + <%= f.label :starts_at %> + <%= f.text_field :starts_at, value: datepicker_field_value(@promotion.starts_at), class: 'datepicker datepicker-from form-control' %> +
    + +
    + <%= f.label :expires_at %> + <%= f.text_field :expires_at, value: datepicker_field_value(@promotion.expires_at), class: 'datepicker datepicker-to form-control' %> +
    +
    +
    diff --git a/backend/app/views/spree/admin/promotions/_promotion_action.html.erb b/backend/app/views/spree/admin/promotions/_promotion_action.html.erb new file mode 100644 index 00000000000..9f6ef11373b --- /dev/null +++ b/backend/app/views/spree/admin/promotions/_promotion_action.html.erb @@ -0,0 +1,12 @@ +
    + <% type_name = promotion_action.class.name.demodulize.underscore %> +
    + <%= link_to_with_icon 'delete', '', spree.admin_promotion_promotion_action_path(@promotion, promotion_action), remote: true, method: :delete, class: 'delete pull-right' %> +

    <%= Spree.t("promotion_action_types.#{type_name}.description") %>

    +
    + <% param_prefix = "promotion[promotion_actions_attributes][#{promotion_action.id}]" %> + <%= hidden_field_tag "#{param_prefix}[id]", promotion_action.id %> + + <%= render partial: "spree/admin/promotions/actions/#{type_name}", + locals: { promotion_action: promotion_action, param_prefix: param_prefix } %> +
    diff --git a/backend/app/views/spree/admin/promotions/_promotion_rule.html.erb b/backend/app/views/spree/admin/promotions/_promotion_rule.html.erb new file mode 100644 index 00000000000..64efe21ab3d --- /dev/null +++ b/backend/app/views/spree/admin/promotions/_promotion_rule.html.erb @@ -0,0 +1,11 @@ +
    + <% type_name = promotion_rule.class.name.demodulize.underscore %> +
    + <%= link_to_with_icon 'delete', '', spree.admin_promotion_promotion_rule_path(@promotion, promotion_rule), remote: true, method: :delete, class: 'delete pull-right' %> +

    '><%= Spree.t("promotion_rule_types.#{type_name}.description") %>

    +
    + <% param_prefix = "promotion[promotion_rules_attributes][#{promotion_rule.id}]" %> + <%= hidden_field_tag "#{param_prefix}[id]", promotion_rule.id %> + + <%= render partial: "spree/admin/promotions/rules/#{type_name}", locals: { promotion_rule: promotion_rule, param_prefix: param_prefix } %> +
    diff --git a/backend/app/views/spree/admin/promotions/_rules.html.erb b/backend/app/views/spree/admin/promotions/_rules.html.erb new file mode 100644 index 00000000000..9752c759b54 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/_rules.html.erb @@ -0,0 +1,46 @@ +
    +

    + <%= Spree.t(:rules) %> +

    +
    +
    + + <%= form_tag spree.admin_promotion_promotion_rules_path(@promotion), remote: true, id: 'new_product_rule_form' do %> + +
    + <%= label_tag :promotion_rule_type, Spree.t(:add_rule_of_type) %> + <%= select_tag('promotion_rule[type]', options_for_promotion_rule_types(@promotion), class: 'select2') %> +
    +
    + <%= button Spree.t(:add), 'plus', 'submit', class: "btn-success" %> +
    + + <% end %> + + <%= form_for @promotion, url: object_url, method: :put do |f| %> +
    + <% Spree::Promotion::MATCH_POLICIES.each do |policy| %> +
    + <%= f.label "match_policy_#{policy}" do %> + <%= f.radio_button :match_policy, policy %> + <%= Spree.t "promotion_form.match_policies.#{policy}" %> + <% end %> +
    + <% end %> +
    + +
    + <% if @promotion.rules.any? %> + <%= render partial: 'promotion_rule', collection: @promotion.rules, locals: {} %> + <% else %> +
    + <%= Spree.t(:no_rules_added) %> +
    + <% end %> +
    + +
    + <%= button Spree.t('actions.update'), 'ok' %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/promotions/actions/_create_adjustment.html.erb b/backend/app/views/spree/admin/promotions/actions/_create_adjustment.html.erb new file mode 100644 index 00000000000..422f971a3d1 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/actions/_create_adjustment.html.erb @@ -0,0 +1,30 @@ +
    +
    +
    + <% field_name = "#{param_prefix}[calculator_type]" %> + <%= label_tag field_name, Spree.t(:calculator) %> + <%= select_tag field_name, + options_from_collection_for_select(@calculators, :to_s, :description, promotion_action.calculator.type), + class: 'type-select select2' %> +
    + <% unless promotion_action.new_record? %> +
    + <% type_name = promotion_action.calculator.type.demodulize.underscore %> + <% if lookup_context.exists?("fields", + ["spree/admin/promotions/calculators/#{type_name}"], true) %> + <%= render "spree/admin/promotions/calculators/#{type_name}/fields", + calculator: promotion_action.calculator, prefix: param_prefix %> + <% else %> + <%= render "spree/admin/promotions/calculators/default_fields", + calculator: promotion_action.calculator, prefix: param_prefix %> + <% end %> + <%= hidden_field_tag "#{param_prefix}[calculator_attributes][id]", promotion_action.calculator.id %> +
    + <% end %> +
    + <% if promotion_action.calculator.respond_to?(:preferences) %> +
    + <%= Spree.t(:calculator_settings_warning) %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/promotions/actions/_create_item_adjustments.html.erb b/backend/app/views/spree/admin/promotions/actions/_create_item_adjustments.html.erb new file mode 100644 index 00000000000..ae26b9905a3 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/actions/_create_item_adjustments.html.erb @@ -0,0 +1,28 @@ +
    +
    +
    + <% field_name = "#{param_prefix}[calculator_type]" %> + <%= label_tag field_name, Spree.t(:calculator) %> + <%= select_tag field_name, + options_from_collection_for_select(Spree::Promotion::Actions::CreateItemAdjustments.calculators, :to_s, :description, promotion_action.calculator.type), + class: 'type-select select2' %> +
    + <% unless promotion_action.new_record? %> +
    + <% promotion_action.calculator.preferences.keys.map do |key| %> + <% field_name = "#{param_prefix}[calculator_attributes][preferred_#{key}]" %> + <%= label_tag field_name, Spree.t(key.to_s) %> + <%= preference_field_tag(field_name, + promotion_action.calculator.get_preference(key), + type: promotion_action.calculator.preference_type(key)) %> + <% end %> + <%= hidden_field_tag "#{param_prefix}[calculator_attributes][id]", promotion_action.calculator.id %> +
    + <% end %> +
    + <% if promotion_action.calculator.respond_to?(:preferences) %> +
    + <%= Spree.t(:calculator_settings_warning) %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/promotions/actions/_create_line_items.html.erb b/backend/app/views/spree/admin/promotions/actions/_create_line_items.html.erb new file mode 100644 index 00000000000..cd2bb14d303 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/actions/_create_line_items.html.erb @@ -0,0 +1,27 @@ +
    + <% promotion_action.promotion_action_line_items.each do |item| %> + <% variant = item.variant %> + + <%= item.quantity %> x <%= variant.name %> + <%= variant.options_text %> + <% end %> + + <% if promotion_action.promotion_action_line_items.empty? %> + <% line_items = promotion_action.promotion_action_line_items %> + <% line_items.build %> + + <% line_items.each_with_index do |line_item, index| %> +
    + <% line_item_prefix = "#{param_prefix}[promotion_action_line_items_attributes][#{index}]" %> +
    + <%= label_tag "#{line_item_prefix}_variant_id", Spree.t(:variant) %> + <%= hidden_field_tag "#{line_item_prefix}[variant_id]", line_item.variant_id, class: "variant_autocomplete fullwidth-input" %> +
    +
    + <%= label_tag "#{line_item_prefix}_quantity", Spree.t(:quantity) %> + <%= number_field_tag "#{line_item_prefix}[quantity]", line_item.quantity, min: 1, class: 'form-control' %> +
    +
    + <% end %> + <% end %> +
    diff --git a/vendor/plugins/acts_as_tree/test/fixtures/mixins.yml b/backend/app/views/spree/admin/promotions/actions/_free_shipping.html.erb similarity index 100% rename from vendor/plugins/acts_as_tree/test/fixtures/mixins.yml rename to backend/app/views/spree/admin/promotions/actions/_free_shipping.html.erb diff --git a/backend/app/views/spree/admin/promotions/calculators/_default_fields.html.erb b/backend/app/views/spree/admin/promotions/calculators/_default_fields.html.erb new file mode 100644 index 00000000000..5e2b27e0cb4 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/calculators/_default_fields.html.erb @@ -0,0 +1,10 @@ +<% calculator.preferences.keys.map do |key| %> +
    + <% field_name = "#{prefix}[calculator_attributes][preferred_#{key}]" %> + <%= label_tag field_name, Spree.t(key.to_s) %> + <%= preference_field_tag( + field_name, + calculator.get_preference(key), + type: calculator.preference_type(key)) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb b/backend/app/views/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb new file mode 100644 index 00000000000..0807cf99a7b --- /dev/null +++ b/backend/app/views/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb @@ -0,0 +1,42 @@ +
    + <%= label_tag "#{prefix}[calculator_attributes][preferred_base_amount]", + Spree.t(:base_amount) %> + <%= preference_field_tag( + "#{prefix}[calculator_attributes][preferred_base_amount]", + calculator.preferred_base_amount, + type: calculator.preference_type(:base_amount)) %> +
    + +
    + <%= label_tag nil, Spree.t(:tiers) %> + <%= content_tag :div, nil, class: "hidden js-original-tiers", + data: { :'original-tiers' => Hash[calculator.preferred_tiers.sort] } %> +
    + +
    + + + + + diff --git a/backend/app/views/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb b/backend/app/views/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb new file mode 100644 index 00000000000..722f195836f --- /dev/null +++ b/backend/app/views/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb @@ -0,0 +1,42 @@ +
    + <%= label_tag "#{prefix}[calculator_attributes][preferred_base_percent]", + Spree.t(:base_percent) %> + <%= preference_field_tag( + "#{prefix}[calculator_attributes][preferred_base_percent]", + calculator.preferred_base_percent, + type: calculator.preference_type(:base_percent)) %> +
    + +
    + <%= label_tag nil, Spree.t(:tiers) %> + <%= content_tag :div, nil, class: "hidden js-original-tiers", + data: { :'original-tiers' => Hash[calculator.preferred_tiers.sort] } %> +
    + +
    + + + + + diff --git a/backend/app/views/spree/admin/promotions/edit.html.erb b/backend/app/views/spree/admin/promotions/edit.html.erb new file mode 100644 index 00000000000..229b180dddb --- /dev/null +++ b/backend/app/views/spree/admin/promotions/edit.html.erb @@ -0,0 +1,30 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:promotions), admin_promotions_url %> / + <%= @promotion.name %> +<% end %> + +<%= form_for @promotion, url: object_url, method: :put do |f| %> +
    +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +
    + +<% end %> + +
    +
    +
    + <%= render partial: 'rules' %> +
    +
    + +
    +
    + <%= render partial: 'actions' %> +
    +
    +
    + +<%= render partial: "spree/admin/variants/autocomplete", formats: [:js] %> diff --git a/backend/app/views/spree/admin/promotions/index.html.erb b/backend/app/views/spree/admin/promotions/index.html.erb new file mode 100644 index 00000000000..7856e197d02 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/index.html.erb @@ -0,0 +1,83 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Promotion) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_promotion), new_object_url, class: "btn-success", icon: 'add' %> +<% end if can?(:create, Spree::Promotion) %> + +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, @search] do |f| %> +
    +
    +
    + <%= label_tag :q_name_cont, Spree.t(:name) %> + <%= f.text_field :name_cont, tabindex: 1, class: "form-control js-quick-search-target js-filterable" %> +
    + +
    + <%= label_tag :q_code_cont, Spree.t(:code) %> + <%= f.text_field :code_cont, tabindex: 1, class: "form-control js-filterable" %> +
    +
    + +
    +
    + <%= label_tag :q_path_cont, Spree.t(:path) %> + <%= f.text_field :path_cont, tabindex: 1, class: "form-control js-filterable" %> +
    + +
    + <%= label_tag :q_promotion_category_id_eq, Spree.t(:promotion_category) %> + <%= f.collection_select(:promotion_category_id_eq, @promotion_categories, :id, :name, { include_blank: Spree.t('match_choices.all') }, { class: 'select2 js-filterable' }) %> +
    +
    +
    + +
    + <%= button Spree.t(:filter_results), 'search' %> +
    + <% end %> +
    +<% end %> + +<%= paginate @promotions %> + +<% if @promotions.any? %> + + + + + + + + + + + + + + <% @promotions.each do |promotion| %> + + + + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:code) %><%= Spree.t(:description) %><%= Spree.t(:usage_limit) %><%= Spree.t(:promotion_uses) %><%= Spree.t(:expiration) %>
    <%= link_to promotion.name, edit_admin_promotion_path(promotion) %><%= promotion.code %><%= promotion.description %><%= promotion.usage_limit.nil? ? "∞" : promotion.usage_limit %><%= Spree.t(:current_promotion_usage, count: promotion.credits_count) %><%= promotion.expires_at.to_date.to_s(:short_date) if promotion.expires_at %> + <%= link_to_edit promotion, no_text: true if can?(:edit, promotion) %> + <%= link_to_clone_promotion promotion, no_text: true if can?(:clone, promotion) %> + <%= link_to_delete promotion, no_text: true if can?(:delete, promotion) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Promotion)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::Promotion) %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotions/new.html.erb b/backend/app/views/spree/admin/promotions/new.html.erb new file mode 100644 index 00000000000..9fe579ee402 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:promotions), admin_promotions_url %> / + <%= Spree.t(:new_promotion) %> +<% end %> + +<%= form_for :promotion, url: collection_url do |f| %> + <%= render partial: 'form', locals: { f: f } %> +
    + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/promotions/rules/_country.html.erb b/backend/app/views/spree/admin/promotions/rules/_country.html.erb new file mode 100644 index 00000000000..ae810196504 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_country.html.erb @@ -0,0 +1,6 @@ +
    +
    + <%= label_tag Spree.t('country_rule.label') %> + <%= select_tag("#{param_prefix}[preferred_country_id]", options_for_select(Spree::Country.pluck(:name, :id), promotion_rule.preferred_country_id), { class: 'select_product form-control' }) %> +
    +
    diff --git a/vendor/plugins/acts_as_tree/test/schema.rb b/backend/app/views/spree/admin/promotions/rules/_first_order.html.erb similarity index 100% rename from vendor/plugins/acts_as_tree/test/schema.rb rename to backend/app/views/spree/admin/promotions/rules/_first_order.html.erb diff --git a/backend/app/views/spree/admin/promotions/rules/_item_total.html.erb b/backend/app/views/spree/admin/promotions/rules/_item_total.html.erb new file mode 100644 index 00000000000..651a444c120 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_item_total.html.erb @@ -0,0 +1,12 @@ +
    +
    +
    + <%= select_tag "#{param_prefix}[preferred_operator_min]", options_for_select(Spree::Promotion::Rules::ItemTotal::OPERATORS_MIN.map{|o| [Spree.t("item_total_rule.operators.#{o}"),o]}, promotion_rule.preferred_operator_min), { class: 'select2 select_item_total marginb' } %> + <%= select_tag "#{param_prefix}[preferred_operator_max]", options_for_select(Spree::Promotion::Rules::ItemTotal::OPERATORS_MAX.map{|o| [Spree.t("item_total_rule.operators.#{o}"),o]}, promotion_rule.preferred_operator_max), { class: 'select2 select_item_total' } %> +
    +
    + <%= text_field_tag "#{param_prefix}[preferred_amount_min]", promotion_rule.preferred_amount_min, class: 'form-control marginb' %> + <%= text_field_tag "#{param_prefix}[preferred_amount_max]", promotion_rule.preferred_amount_max, class: 'form-control' %> +
    +
    +
    diff --git a/vendor/plugins/enumerable_constants/install.rb b/backend/app/views/spree/admin/promotions/rules/_one_use_per_user.html.erb similarity index 100% rename from vendor/plugins/enumerable_constants/install.rb rename to backend/app/views/spree/admin/promotions/rules/_one_use_per_user.html.erb diff --git a/backend/app/views/spree/admin/promotions/rules/_option_value.html.erb b/backend/app/views/spree/admin/promotions/rules/_option_value.html.erb new file mode 100644 index 00000000000..2c8173e7dc5 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_option_value.html.erb @@ -0,0 +1,42 @@ +
    +
    +
    + + +
    +
    + +<%= content_tag :div, nil, class: "hidden js-original-promo-rule-option-values", + data: { :'original-option-values' => promotion_rule.preferred_eligible_values } %> + + + + diff --git a/backend/app/views/spree/admin/promotions/rules/_product.html.erb b/backend/app/views/spree/admin/promotions/rules/_product.html.erb new file mode 100644 index 00000000000..cb98c092137 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_product.html.erb @@ -0,0 +1,10 @@ +
    +
    + <%= label_tag "#{param_prefix}_product_ids_string", Spree.t('product_rule.choose_products') %> + <%= hidden_field_tag "#{param_prefix}[product_ids_string]", promotion_rule.product_ids.join(","), class: "product_picker" %> +
    +
    + <%= label_tag Spree.t('product_rule.label') %> + <%= select_tag("#{param_prefix}[preferred_match_policy]", options_for_select(Spree::Promotion::Rules::Product::MATCH_POLICIES.map{|s| [Spree.t("product_rule.match_#{s}"),s] }, promotion_rule.preferred_match_policy), {class: 'select_product form-control'}) %> +
    +
    diff --git a/backend/app/views/spree/admin/promotions/rules/_taxon.html.erb b/backend/app/views/spree/admin/promotions/rules/_taxon.html.erb new file mode 100644 index 00000000000..e89cc5d19dc --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_taxon.html.erb @@ -0,0 +1,10 @@ +
    +
    + <%= label_tag "#{param_prefix}_taxon_ids_string", Spree.t('taxon_rule.choose_taxons') %> + <%= hidden_field_tag "#{param_prefix}[taxon_ids_string]", promotion_rule.taxon_ids.join(","), class: "taxon_picker", id: 'product_taxon_ids' %> +
    +
    + <%= label_tag Spree.t('taxon_rule.label') %> + <%= select_tag("#{param_prefix}[preferred_match_policy]", options_for_select(Spree::Promotion::Rules::Taxon::MATCH_POLICIES.map{|s| [Spree.t("taxon_rule.match_#{s}"),s] }, promotion_rule.preferred_match_policy), {class: 'select_taxon form-control'}) %> +
    +
    diff --git a/backend/app/views/spree/admin/promotions/rules/_user.html.erb b/backend/app/views/spree/admin/promotions/rules/_user.html.erb new file mode 100644 index 00000000000..ea848ba58f0 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_user.html.erb @@ -0,0 +1,6 @@ +
    +
    + <%= label_tag Spree.t('user_rule.choose_users') %> + +
    +
    diff --git a/vendor/plugins/rspec_on_rails/generators/rspec/templates/previous_failures.txt b/backend/app/views/spree/admin/promotions/rules/_user_logged_in.html.erb similarity index 100% rename from vendor/plugins/rspec_on_rails/generators/rspec/templates/previous_failures.txt rename to backend/app/views/spree/admin/promotions/rules/_user_logged_in.html.erb diff --git a/backend/app/views/spree/admin/properties/_form.html.erb b/backend/app/views/spree/admin/properties/_form.html.erb new file mode 100644 index 00000000000..51280094b54 --- /dev/null +++ b/backend/app/views/spree/admin/properties/_form.html.erb @@ -0,0 +1,16 @@ +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    +
    + <%= f.field_container :presentation, class: ['form-group'] do %> + <%= f.label :presentation, Spree.t(:presentation) %> * + <%= f.text_field :presentation, class: 'form-control' %> + <%= f.error_message_on :presentation %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/properties/edit.html.erb b/backend/app/views/spree/admin/properties/edit.html.erb new file mode 100644 index 00000000000..6e5e8310517 --- /dev/null +++ b/backend/app/views/spree/admin/properties/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:properties), admin_properties_url %> / + <%= @property.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @property } %> + +<%= form_for [:admin, @property] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/properties/index.html.erb b/backend/app/views/spree/admin/properties/index.html.erb new file mode 100644 index 00000000000..58afc09a403 --- /dev/null +++ b/backend/app/views/spree/admin/properties/index.html.erb @@ -0,0 +1,68 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Property) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_property), new_object_url, { class: "btn-success", icon: 'add', 'data-update' => 'new_property', id: 'new_property_link' } %> +<% end if can?(:create, Spree::Property) %> + +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, @search] do |f| %> + + <%- locals = {f: f} %> + +
    +
    +
    + <%= f.label :name_cont, Spree.t(:name) %> + <%= f.text_field :name_cont, class: "form-control js-quick-search-target js-filterable" %> +
    +
    + +
    +
    + <%= f.label :presentation_cont, Spree.t(:presentation) %> + <%= f.text_field :presentation_cont, class: "form-control js-filterable" %> +
    +
    +
    + +
    + <%= button Spree.t(:search), 'search' %> +
    + + <% end %> +
    +<% end %> + +<% if @properties.any? %> + + + + + + + + + + <% @properties.each do |property| %> + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:presentation) %>
    <%= property.name %><%= property.presentation %> + <%= link_to_edit(property, no_text: true) if can?(:edit, property) %> + <%= link_to_delete(property, no_text: true) if can?(:delete, property) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Property)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::OptionType) %>! +
    +<% end %> + +<%= paginate @collection %> diff --git a/backend/app/views/spree/admin/properties/new.html.erb b/backend/app/views/spree/admin/properties/new.html.erb new file mode 100644 index 00000000000..51dea1a838e --- /dev/null +++ b/backend/app/views/spree/admin/properties/new.html.erb @@ -0,0 +1,15 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @property } %> + +<% content_for :page_title do %> + <%= link_to Spree.t(:properties), admin_properties_url %> / + <%= Spree.t(:new_property) %> +<% end %> + +<%= form_for [:admin, @property] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> +
    + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/prototypes/_form.html.erb b/backend/app/views/spree/admin/prototypes/_form.html.erb new file mode 100644 index 00000000000..9d65265a863 --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/_form.html.erb @@ -0,0 +1,28 @@ +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> *
    + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + +
    + <%= f.field_container :property_ids, class: ['form-group'] do %> + <%= f.label :property_ids, Spree.t(:properties) %>
    + <%= f.select :property_ids, Spree::Property.all.map { |p| ["#{p.presentation} (#{p.name})", p.id] }, {}, { multiple: true, class: "select2" } %> + <% end %> +
    + +
    + <%= f.field_container :option_type_ids, class: ['form-group'] do %> + <%= f.label :option_type_ids, Spree.t(:option_types) %>
    + <%= f.select :option_type_ids, Spree::OptionType.all.map { |ot| ["#{ot.presentation} (#{ot.name})", ot.id] }, {}, { multiple: true, class: "select2" } %> + <% end %> +
    + +
    + <%= f.field_container :taxon_ids, class: ['form-group'] do %> + <%= f.label :taxon_ids, Spree.t(:taxons) %>
    + <%= f.select :taxon_ids, Spree::Taxon.all.map { |t| [t.name, t.id] }, {}, { multiple: true, class: "select2" } %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/prototypes/_prototypes.html.erb b/backend/app/views/spree/admin/prototypes/_prototypes.html.erb new file mode 100644 index 00000000000..c0659d1604f --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/_prototypes.html.erb @@ -0,0 +1,21 @@ + + + + + + + + + <% @prototypes.each do |prototype| %> + + + + + <% end %> + <% if @prototypes.empty? %> + + <% end %> + +
    <%= Spree.t(:name) %>
    <%= prototype.name %> + <%= link_to_with_icon 'save', Spree.t(:select), select_admin_prototype_url(prototype), class: ' btn btn-default btn-sm ajax select_properties_from_prototype', no_text: true %> +
    <% Spree.t(:none) %>.
    diff --git a/backend/app/views/spree/admin/prototypes/available.js.erb b/backend/app/views/spree/admin/prototypes/available.js.erb new file mode 100644 index 00000000000..b9510b45af1 --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/available.js.erb @@ -0,0 +1,2 @@ +$("#prototypes").html('<%= escape_javascript(render partial: "spree/admin/prototypes/prototypes") %>'); +$(".js-new-ptype-link").hide(); diff --git a/backend/app/views/spree/admin/prototypes/edit.html.erb b/backend/app/views/spree/admin/prototypes/edit.html.erb new file mode 100644 index 00000000000..1e370fa693f --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:prototypes), spree.admin_prototypes_url %> / + <%= @prototype.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @prototype } %> + +<%= form_for [:admin, @prototype] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/prototypes/index.html.erb b/backend/app/views/spree/admin/prototypes/index.html.erb new file mode 100644 index 00000000000..67c7e4d11bd --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Prototype) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_prototype), new_object_url, { class: "btn-success", icon: 'add', 'data-update' => 'new_prototype', id: 'new_prototype_link'} %> +<% end if can?(:create, Spree::Prototype) %> + +<% if @prototypes.any? %> + + + + + + + + + <% @prototypes.each do |prototype| %> + + + + + <% end %> + +
    <%= Spree.t(:name) %>
    <%= prototype.name %> + <%= link_to_edit(prototype, no_text: true, class: 'admin_edit_prototype') if can?(:edit, prototype) %> + <%= link_to_delete(prototype, no_text: true) if can?(:delete, prototype) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Prototype)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::Prototype) %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/prototypes/new.html.erb b/backend/app/views/spree/admin/prototypes/new.html.erb new file mode 100644 index 00000000000..837265b4c4a --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/new.html.erb @@ -0,0 +1,13 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @prototype } %> + +<% content_for :page_title do %> + <%= link_to Spree.t(:prototypes), spree.admin_prototypes_url %> / + <%= Spree.t(:new_prototype) %> +<% end %> + +<%= form_for [:admin, @prototype] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/prototypes/select.js.erb b/backend/app/views/spree/admin/prototypes/select.js.erb new file mode 100644 index 00000000000..f31df83002d --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/select.js.erb @@ -0,0 +1,4 @@ +<% @prototype_properties.sort_by{ |prop| -prop[:id] }.each do |prop| %> + $("a.spree_add_fields").click(); + $(".product_property.fields:first input[type=text]:first").val("<%= prop.name %>"); +<% end %> diff --git a/backend/app/views/spree/admin/prototypes/show.html.erb b/backend/app/views/spree/admin/prototypes/show.html.erb new file mode 100644 index 00000000000..b35660c885c --- /dev/null +++ b/backend/app/views/spree/admin/prototypes/show.html.erb @@ -0,0 +1,39 @@ +<% if @prototype.option_types.present? %> +

    <%= Spree.t(:variants) %>

    + +
      + <% @prototype.option_types.each do |ot| %> +
    • + + <%= check_box_tag "option_types[]", ot.id, (params[:option_types] || []).include?(ot.id.to_s), id: "option_type_#{ot.id}", class: "option-type" %> + <%= label_tag "option_type_#{ot.id}", ot.presentation %> + +
        + <% ot.option_values.each do |ov| %> +
      • + <%= check_box_tag "product[option_values_hash[#{ot.id}]][]", ov.id, params[:product] && (params[:product][:option_values_hash] || {}).values.flatten.include?(ov.id.to_s), id: "option_value_#{ov.id}", class: "option-value" %> + <%= label_tag "option_value_#{ov.id}", ov.presentation %> +
      • + <% end %> +
      +
    • + <% end %> +
    + + +<% end %> diff --git a/backend/app/views/spree/admin/refund_reasons/edit.html.erb b/backend/app/views/spree/admin/refund_reasons/edit.html.erb new file mode 100755 index 00000000000..6253c207e25 --- /dev/null +++ b/backend/app/views/spree/admin/refund_reasons/edit.html.erb @@ -0,0 +1,3 @@ +<%= render partial: 'spree/admin/shared/named_types/edit', locals: { + collection_url_label: Spree.t(:refund_reasons) +} %> diff --git a/backend/app/views/spree/admin/refund_reasons/index.html.erb b/backend/app/views/spree/admin/refund_reasons/index.html.erb new file mode 100755 index 00000000000..4d103cc98d7 --- /dev/null +++ b/backend/app/views/spree/admin/refund_reasons/index.html.erb @@ -0,0 +1,6 @@ +<%= render partial: 'spree/admin/shared/named_types/index', locals: { + page_title: plural_resource_name(Spree::RefundReason), + new_button_text: Spree.t(:new_refund_reason), + resource_name: plural_resource_name(Spree::RefundReason), + resource: Spree::RefundReason +} %> diff --git a/backend/app/views/spree/admin/refund_reasons/new.html.erb b/backend/app/views/spree/admin/refund_reasons/new.html.erb new file mode 100755 index 00000000000..6ec309047ae --- /dev/null +++ b/backend/app/views/spree/admin/refund_reasons/new.html.erb @@ -0,0 +1,4 @@ +<%= render partial: 'spree/admin/shared/named_types/new', locals: { + collection_url_label: Spree.t(:refund_reasons), + page_title: Spree.t(:new_refund_reason) +} %> diff --git a/backend/app/views/spree/admin/refunds/edit.html.erb b/backend/app/views/spree/admin/refunds/edit.html.erb new file mode 100644 index 00000000000..e737cbe4e72 --- /dev/null +++ b/backend/app/views/spree/admin/refunds/edit.html.erb @@ -0,0 +1,29 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :payments } %> + +<% content_for :page_title do %> + / <%= link_to "#{Spree.t(:payment)} #{@refund.payment.id}", spree.admin_order_payment_path(@refund.payment.order, @refund.payment) %> + / <%= Spree.t(:refund) %> <%= @refund.id %> +<% end %> + +<%= render 'spree/admin/shared/error_messages', target: @refund %> + +<%= form_for [:admin, @refund.payment.order, @refund.payment, @refund] do |f| %> +
    +
    +
    + <%= f.label :amount, Spree.t(:amount) %>
    + <%= @refund.amount %> +
    +
    + <%= f.label :refund_reason_id, Spree.t(:reason) %>
    + <%= f.collection_select(:refund_reason_id, refund_reasons, :id, :name, {}, {class: 'select2'}) %> +
    +
    + +
    + <%= button Spree.t('actions.save'), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_payments_url(@refund.payment.order), icon: "delete" %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/refunds/new.html.erb b/backend/app/views/spree/admin/refunds/new.html.erb new file mode 100644 index 00000000000..b64afe0076f --- /dev/null +++ b/backend/app/views/spree/admin/refunds/new.html.erb @@ -0,0 +1,39 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: {current: :payments} %> + +<% content_for :page_title do %> + / <%= link_to "#{Spree.t(:payment)} #{@refund.payment.id}", spree.admin_order_payment_path(@refund.payment.order, @refund.payment) %> + / <%= Spree.t(:new_refund) %> +<% end %> + +<%= render 'spree/admin/shared/error_messages', target: @refund %> + +<%= form_for [:admin, @refund.payment.order, @refund.payment, @refund] do |f| %> +
    +
    +
    + <%= f.label :payment_amount, Spree.t(:payment_amount) %>
    + <%= @refund.payment.amount %> +
    +
    + <%= f.label :credit_allowed, Spree.t(:credit_allowed) %>
    + <%= @refund.payment.credit_allowed %> +
    + <%= f.field_container :amount, class: ['form-group'] do %> + <%= f.label :amount, Spree.t(:amount) %> + <%= f.text_field :amount, class: 'form-control' %> + <%= f.error_message_on :amount %> + <% end %> + <%= f.field_container :reason, class: ['form-group'] do %> + <%= f.label :refund_reason_id, Spree.t(:reason) %> + <%= f.collection_select(:refund_reason_id, refund_reasons, :id, :name, {include_blank: true}, {class: 'select2'}) %> + <%= f.error_message_on :reason %> + <% end %> +
    + +
    + <%= button Spree.t(:refund), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_payments_url(@refund.payment.order), icon: "delete" %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/reimbursement_types/_form.html.erb b/backend/app/views/spree/admin/reimbursement_types/_form.html.erb new file mode 100644 index 00000000000..bfdd92609d2 --- /dev/null +++ b/backend/app/views/spree/admin/reimbursement_types/_form.html.erb @@ -0,0 +1,32 @@ +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class:'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    + <%- if @reimbursement_type.new_record? %> +
    + <%= f.field_container :type, class: ['form-group'] do %> + <%= f.label :type, Spree.t(:type) %> + <%= f.select :type, options_for_select(Spree::ReimbursementType::KINDS), {:include_blank => false}, { :class => 'select2' } %> + <%= f.error_message_on :type %> + <% end %> +
    + <% end %> +
    + <%= f.field_container :active, class: ['form-inline'] do %> + <%= f.check_box :active, class: 'form-control' %> + <%= f.label :active, Spree.t(:active) %> + <%= f.error_message_on :active %> + <% end %> +
    +
    + <%= f.field_container :mutable, class: ['form-inline'] do %> + <%= f.check_box :mutable, class: 'form-control' %> + <%= f.label :mutable, Spree.t(:mutable) %> + <%= f.error_message_on :mutable %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/reimbursement_types/edit.html.erb b/backend/app/views/spree/admin/reimbursement_types/edit.html.erb new file mode 100644 index 00000000000..9a31337014a --- /dev/null +++ b/backend/app/views/spree/admin/reimbursement_types/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:reimbursement_types), spree.admin_reimbursement_types_url %> / + <%= @reimbursement_type.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @reimbursement_type } %> + +<%= form_for @reimbursement_type, :url => object_url, :method => :put do |reimbursement_form| %> + <%= render partial: 'form', locals: { f: reimbursement_form } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/admin/reimbursement_types/index.html.erb b/backend/app/views/spree/admin/reimbursement_types/index.html.erb new file mode 100644 index 00000000000..96688c2360d --- /dev/null +++ b/backend/app/views/spree/admin/reimbursement_types/index.html.erb @@ -0,0 +1,39 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::ReimbursementType) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_reimbursement_type), new_object_url, { :class => "btn-success", :icon => 'add', :id => 'admin_new_reimbursement_type' } %> +<% end if can?(:create, Spree::ReimbursementType) %> + +<% if @reimbursement_types.any? %> + + + + + + + + + + <% @reimbursement_types.each do |reimbursement_type| %> + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:type) %>
    + <%= reimbursement_type.name.humanize %> + + <%= reimbursement_type.type %> + + <%= link_to_edit reimbursement_type, no_text: true, class: 'edit' if can?(:edit, reimbursement_type) %> + <%= link_to_delete reimbursement_type, no_text: true if can?(:delete, reimbursement_type) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::ReimbursementType)) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/reimbursement_types/new.html.erb b/backend/app/views/spree/admin/reimbursement_types/new.html.erb new file mode 100644 index 00000000000..eb4bc0cde87 --- /dev/null +++ b/backend/app/views/spree/admin/reimbursement_types/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:reimbursement_types), spree.admin_reimbursement_types_url %> / + <%= Spree.t(:new_reimbursement_type) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @reimbursement_type } %> + +<%= form_for [:admin, @reimbursement_type] do |reimbursement_form| %> + <%= render partial: 'form', locals: { f: reimbursement_form } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/admin/reimbursements/edit.html.erb b/backend/app/views/spree/admin/reimbursements/edit.html.erb new file mode 100644 index 00000000000..c6bee5fab68 --- /dev/null +++ b/backend/app/views/spree/admin/reimbursements/edit.html.erb @@ -0,0 +1,102 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :customer_returns } %> + +<% content_for :page_title do %> + / <%= Spree.t(:reimbursement) %> #<%= @reimbursement.number %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @reimbursement } %> + +<%= form_for [:admin, @order, @reimbursement] do |f| %> +
    + <%= Spree.t(:items_to_be_reimbursed) %> + + + + + + + + + + + + + <%= f.fields_for :return_items, @reimbursement.return_items.sort_by(&:id) do |item_fields| %> + <% return_item = item_fields.object %> + + + + + + + + + <% end %> + +
    <%= Spree.t(:product) %><%= Spree.t(:preferred_reimbursement_type) %><%= Spree.t(:reimbursement_type_override) %><%= Spree.t(:pre_tax_refund_amount) %><%= Spree.t(:total) %><%= Spree.t(:exchange_for) %>
    +
    <%= return_item.inventory_unit.variant.name %>
    +
    <%= return_item.inventory_unit.variant.options_text %>
    +
    + <%= reimbursement_type_name(return_item.preferred_reimbursement_type) %> + + <%= item_fields.select(:override_reimbursement_type_id, + reimbursement_types.collect { |r| [r.name.humanize, r.id] }, + {include_blank: true}, + {class: 'select2'} + ) %> + + <%= item_fields.text_field :pre_tax_amount, { class: 'refund-amount-input form-control' } %> + + <%= return_item.display_total %> + + <% if return_item.exchange_processed? %> + <%= return_item.exchange_variant.exchange_name %> + <% else %> + <%= item_fields.collection_select :exchange_variant_id, return_item.eligible_exchange_variants, :id, :exchange_name, { include_blank: true }, { class: "select2 return-item-exchange-selection" } %> + <% end %> +
    +
    + +
    + <%= button Spree.t('actions.update'), 'update' %> +
    +<% end %> + +
    + <%= Spree.t(:calculated_reimbursements) %> + + + + + + + + + + <% @reimbursement_objects.each do |reimbursement_object| %> + + + + + + <% end %> + +
    <%= Spree.t(:reimbursement_type) %><%= Spree.t(:description) %><%= Spree.t(:amount) %>
    <%= reimbursement_object.class.name.demodulize %><%= reimbursement_object.description %><%= reimbursement_object.display_amount %>
    + + <% if @order.has_non_reimbursement_related_refunds? %> +
    + <%= "#{Spree.t('note')}: #{Spree.t('this_order_has_already_received_a_refund')}. #{Spree.t('make_sure_the_above_reimbursement_amount_is_correct')}." %> +
    + <% end %> + +
    + <% if !@reimbursement.reimbursed? %> + <%= button_to [:perform, :admin, @order, @reimbursement], { class: 'btn btn-primary', method: 'post' } do %> + + <%= Spree.t(:reimburse) %> + <% end %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), url_for([:edit, :admin, @order, @reimbursement.customer_return]), icon: 'remove-sign' %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/reimbursements/index.html.erb b/backend/app/views/spree/admin/reimbursements/index.html.erb new file mode 100644 index 00000000000..80eff4c860f --- /dev/null +++ b/backend/app/views/spree/admin/reimbursements/index.html.erb @@ -0,0 +1,28 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :reimbursements } %> + +<% content_for :page_title do %> + / <%= Spree.t(:edit_reimbursement) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @customer_return } %> + + + + + + + + + + + + <% @reimbursements.each do |reimbursement| %> + + + + + + + <% end %> + +
    <%= Spree.t(:id) %><%= Spree.t(:total) %><%= Spree.t(:status) %><%= "#{Spree.t('date')}/#{Spree.t('time')}" %>
    <%= reimbursement.id %><%= reimbursement.total %><%= reimbursement.reimbursement_status %><%= pretty_time(reimbursement.created_at) %>
    diff --git a/backend/app/views/spree/admin/reimbursements/show.html.erb b/backend/app/views/spree/admin/reimbursements/show.html.erb new file mode 100644 index 00000000000..1998fe4df28 --- /dev/null +++ b/backend/app/views/spree/admin/reimbursements/show.html.erb @@ -0,0 +1,90 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :customer_returns } %> + +<% content_for :page_title do %> + / <%= Spree::Reimbursement.model_name.human %> #<%= @reimbursement.number %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @reimbursement } %> + +
    + <%= Spree.t(:items_reimbursed) %> + + + + + + + + + + + + + <% @reimbursement.return_items.each do |return_item| %> + + + + + + + + + <% end %> + +
    <%= Spree.t(:product) %><%= Spree.t(:preferred_reimbursement_type) %><%= Spree.t(:reimbursement_type_override) %><%= Spree.t(:exchange_for) %><%= Spree.t(:pre_tax_amount) %><%= Spree.t(:total) %>
    +
    <%= return_item.inventory_unit.variant.name %>
    +
    <%= return_item.inventory_unit.variant.options_text %>
    +
    + <%= reimbursement_type_name(return_item.preferred_reimbursement_type) %> + + <%= reimbursement_type_name(return_item.override_reimbursement_type) %> + + <%= return_item.exchange_variant.try(:exchange_name) %> + + <%= return_item.display_pre_tax_amount %> + + <%= return_item.display_total %> +
    +
    + +
    + <%= Spree.t(:refunds) %> + + + + + + + + + <% @reimbursement.refunds.each do |refund| %> + + + + + <% end %> + +
    <%= Spree.t(:description) %><%= Spree.t(:amount) %>
    <%= refund.description %><%= refund.display_amount %>
    +
    + +<% if @reimbursement.credits.any? %> +
    + <%= Spree.t(:credits) %> + + + + + + + + + <% @reimbursement.credits.each do |credit| %> + + + + + <% end %> + +
    <%= Spree.t(:description) %><%= Spree.t(:amount) %>
    <%= credit.description %><%= credit.display_amount %>
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/reports/index.html.erb b/backend/app/views/spree/admin/reports/index.html.erb new file mode 100644 index 00000000000..c394b34efa7 --- /dev/null +++ b/backend/app/views/spree/admin/reports/index.html.erb @@ -0,0 +1,20 @@ +<% content_for :page_title do %> + <%= Spree.t(:reports) %> +<% end %> + + + + + + + + + + <% @reports.each do |key, value| %> + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:description) %>
    <%= link_to value[:name], send("#{key}_admin_reports_url".to_sym) %><%= value[:description] %>
    diff --git a/backend/app/views/spree/admin/reports/sales_total.html.erb b/backend/app/views/spree/admin/reports/sales_total.html.erb new file mode 100644 index 00000000000..86f9ddc2982 --- /dev/null +++ b/backend/app/views/spree/admin/reports/sales_total.html.erb @@ -0,0 +1,29 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:reports), spree.admin_reports_url %> / + <%= Spree.t(:sales_totals) %> +<% end %> + +
    + <%= render partial: 'spree/admin/shared/report_order_criteria' %> +
    + + + + + + + + + + + + <% @totals.each do |key, row| %> + + + + + + + <% end %> + +
    <%= Spree.t(:currency) %><%= Spree.t(:item_total) %><%= Spree.t(:adjustment_total) %><%= Spree.t(:sales_total) %>
    <%= key %><%= Spree::Money.new(row[:item_total], { currency: key }) %><%= Spree::Money.new(row[:adjustment_total], { currency: key }) %><%= Spree::Money.new(row[:sales_total], { currency: key }) %>
    diff --git a/backend/app/views/spree/admin/return_authorization_reasons/edit.html.erb b/backend/app/views/spree/admin/return_authorization_reasons/edit.html.erb new file mode 100755 index 00000000000..1368b27fe41 --- /dev/null +++ b/backend/app/views/spree/admin/return_authorization_reasons/edit.html.erb @@ -0,0 +1,3 @@ +<%= render partial: 'spree/admin/shared/named_types/edit', locals: { + collection_url_label: Spree.t(:return_authorization_reasons) +} %> diff --git a/backend/app/views/spree/admin/return_authorization_reasons/index.html.erb b/backend/app/views/spree/admin/return_authorization_reasons/index.html.erb new file mode 100755 index 00000000000..bdd1c24c75d --- /dev/null +++ b/backend/app/views/spree/admin/return_authorization_reasons/index.html.erb @@ -0,0 +1,6 @@ +<%= render partial: 'spree/admin/shared/named_types/index', locals: { + page_title: plural_resource_name(Spree::ReturnAuthorizationReason), + new_button_text: Spree.t(:new_rma_reason), + resource_name: plural_resource_name(Spree::ReturnAuthorizationReason), + resource: Spree::RefundReason +} %> diff --git a/backend/app/views/spree/admin/return_authorization_reasons/new.html.erb b/backend/app/views/spree/admin/return_authorization_reasons/new.html.erb new file mode 100755 index 00000000000..7d22ecd390c --- /dev/null +++ b/backend/app/views/spree/admin/return_authorization_reasons/new.html.erb @@ -0,0 +1,4 @@ +<%= render partial: 'spree/admin/shared/named_types/new', locals: { + collection_url_label: Spree.t(:return_authorization_reasons), + page_title: Spree.t(:new_rma_reason) +} %> diff --git a/backend/app/views/spree/admin/return_authorizations/_form.html.erb b/backend/app/views/spree/admin/return_authorizations/_form.html.erb new file mode 100644 index 00000000000..8669529636d --- /dev/null +++ b/backend/app/views/spree/admin/return_authorizations/_form.html.erb @@ -0,0 +1,103 @@ +<% allow_return_item_changes = !@return_authorization.customer_returned_items? %> + +
    + + + + + + + + + + + + + + + + <%= f.fields_for :return_items, @form_return_items do |item_fields| %> + <% return_item = item_fields.object %> + <% inventory_unit = return_item.inventory_unit %> + <% editable = inventory_unit.shipped? && allow_return_item_changes && return_item.reimbursement.nil? %> + + + + + + + + + + + + <% end %> + +
    + <% if allow_return_item_changes %> + <%= check_box_tag 'select-all' %> + <% end %> + <%= Spree.t(:product) %><%= Spree.t(:status) %><%= Spree.t(:charged) %><%= Spree.t(:purchased_quantity) %><%= Spree.t(:return_quantity) %><%= Spree.t(:pre_tax_refund_amount) %><%= Spree.t(:reimbursement_type) %><%= Spree.t(:exchange_for) %>
    + <% if editable %> + <%= item_fields.hidden_field :inventory_unit_id %> + <%= item_fields.check_box :_destroy, {checked: return_item.persisted?, class: 'add-item', "data-price" => return_item.pre_tax_amount}, '0', '1' %> + <% end %> + +
    <%= inventory_unit.variant.name %>
    +
    <%= inventory_unit.variant.options_text %>
    +
    <%= inventory_unit.state.humanize %> + <%= return_item.display_pre_tax_amount %> + + <%= inventory_unit.quantity %> + + <% if editable %> + <%= item_fields.number_field :return_quantity, { class: 'refund-quantity-input form-control', min: 0, max: return_item.return_quantity } %> + <% else %> + <%= return_item.return_quantity %> + <% end %> + + <% if editable %> + <%= item_fields.text_field :pre_tax_amount, { class: 'refund-amount-input form-control' } %> + <% else %> + <%= return_item.display_pre_tax_amount %> + <% end %> + + <% if editable %> + <%= item_fields.select :preferred_reimbursement_type_id, @reimbursement_types.collect{|r|[r.name.humanize, r.id]}, {include_blank: true}, {class: 'select2'} %> + <% end %> + + <% if editable %> + <% if return_item.exchange_processed? %> + <%= return_item.exchange_variant.exchange_name %> + <% else %> + <%= item_fields.collection_select :exchange_variant_id, return_item.eligible_exchange_variants, :id, :exchange_name, { include_blank: true }, { class: "select2 return-item-exchange-selection" } %> + <% end %> + <% end %> +
    + + <%= f.field_container :amount, class: ['alert alert-info'] do %> + <%= Spree.t(:total_pre_tax_refund) %>: 0.00 + <% end %> + + <%= f.field_container :stock_location, class: ['form-group'] do %> + <%= f.label :stock_location, Spree.t(:stock_location) %> * + <%= f.select :stock_location_id, Spree::StockLocation.order_default.active.to_a.collect{|l|[l.name, l.id]}, {include_blank: true}, {class: 'select2', "data-placeholder" => Spree.t(:select_a_stock_location)} %> + <%= f.error_message_on :stock_location %> + <% end %> + + <%= f.field_container :reason, class: ['form-group'] do %> + <%= f.label :reason, Spree.t(:reason) %> * + <%= f.select :return_authorization_reason_id, @reasons.collect{|r|[r.name, r.id]}, {include_blank: true}, {class: 'select2', "data-placeholder" => Spree.t(:select_a_return_authorization_reason)} %> + <%= f.error_message_on :reason %> + <% end %> + + <%= f.field_container :memo, class: ['form-group'] do %> + <%= f.label :memo, Spree.t(:memo) %> + <%= f.text_area :memo, class: 'form-control' %> + <%= f.error_message_on :memo %> + <% end %> +
    + +<% if Spree::Config[:expedited_exchanges] %> +
    <%= Spree.t(:expedited_exchanges_warning, days_window: Spree::Config[:expedited_exchanges_days_window]) %>
    +<% end %> diff --git a/backend/app/views/spree/admin/return_authorizations/edit.html.erb b/backend/app/views/spree/admin/return_authorizations/edit.html.erb new file mode 100644 index 00000000000..5e62aacb458 --- /dev/null +++ b/backend/app/views/spree/admin/return_authorizations/edit.html.erb @@ -0,0 +1,26 @@ +<% content_for :page_actions do %> + <% if @return_authorization.can_cancel? %> + <%= button_link_to Spree.t('actions.cancel'), fire_admin_order_return_authorization_url(@order, @return_authorization, e: 'cancel'), method: :put, data: { confirm: Spree.t(:are_you_sure) }, icon: "delete" %> + <% end %> +<% end %> + +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :return_authorizations } %> + +<% content_for :page_title do %> + / <%= link_to Spree.t(:return_authorizations), spree.admin_order_return_authorizations_url %> + / <%= @return_authorization.number %> (<%= Spree.t(@return_authorization.state.downcase) %>) +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @return_authorization } %> + +<%= form_for [:admin, @order, @return_authorization] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button Spree.t('actions.update'), 'repeat' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_return_authorizations_url(@order), icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/return_authorizations/index.html.erb b/backend/app/views/spree/admin/return_authorizations/index.html.erb new file mode 100644 index 00000000000..50e7c70906d --- /dev/null +++ b/backend/app/views/spree/admin/return_authorizations/index.html.erb @@ -0,0 +1,53 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :return_authorizations } %> + +<% content_for :page_actions do %> + <% if @order.shipments.any?(&:shipped?) && can?(:create, Spree::ReturnAuthorization) %> + <%= button_link_to Spree.t(:new_return_authorization), new_admin_order_return_authorization_url(@order), class: "btn-success", icon: 'add' %> + <% end %> +<% end %> + +<% content_for :page_title do %> + / <%= Spree.t(:return_authorizations) %> +<% end %> + +<% if @order.shipments.any?(&:shipped?) && @order.return_authorizations.any? %> + + + + + + + + + + + + <% @return_authorizations.each do |return_authorization| %> + + + + + + + + <% end %> + +
    <%= Spree.t(:rma_number) %><%= Spree.t(:status) %><%= Spree.t(:pre_tax_total) %><%= "#{Spree.t('date')}/#{Spree.t('time')}" %>
    <%= return_authorization.number %> + + <%= Spree.t("return_authorization_states.#{return_authorization.state}") %> + + <%= return_authorization.display_pre_tax_total.to_html %><%= pretty_time(return_authorization.created_at) %> + <%= link_to_edit(return_authorization, no_text: true, class: 'edit') if can?(:edit, return_authorization) %> + <% if can?(:delete, return_authorization) && !return_authorization.customer_returned_items? %> + <%= link_to_delete return_authorization, no_text: true %> + <% end %> +
    +<% elsif @order.shipments.any?(&:shipped?) %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::ReturnAuthorization)) %> +
    +<% else %> +
    + <%= Spree.t(:cannot_create_returns) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/return_authorizations/new.html.erb b/backend/app/views/spree/admin/return_authorizations/new.html.erb new file mode 100644 index 00000000000..db2f3f45ac1 --- /dev/null +++ b/backend/app/views/spree/admin/return_authorizations/new.html.erb @@ -0,0 +1,18 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :return_authorizations } %> + +<% content_for :page_title do %> + / <%= Spree.t(:new_return_authorization) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @return_authorization } %> +<%= form_for [:admin, @order, @return_authorization] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= button Spree.t(:create), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), spree.admin_order_return_authorizations_url(@order), icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/return_index/customer_returns.html.erb b/backend/app/views/spree/admin/return_index/customer_returns.html.erb new file mode 100644 index 00000000000..9cd8922ad9f --- /dev/null +++ b/backend/app/views/spree/admin/return_index/customer_returns.html.erb @@ -0,0 +1,63 @@ +<% content_for :page_title do %> + <%= Spree.t(:customer_returns) %> +<% end %> + +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, @search], url: spree.admin_customer_returns_path do |f| %> +
    +
    +
    + <%= f.label :number_cont, Spree.t(:number) %> + <%= f.text_field :number_cont, class: "form-control js-quick-search-target js-filterable" %> +
    +
    +
    +
    + <%= button Spree.t(:search), 'search' %> +
    + <% end %> +
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection, per_page_action: :customer_returns } %> + +<% if @collection.any? %> + + + + + + + + + + + + + <% @collection.each do |customer_return| %> + + + + + + + + + <% end %> + +
    <%= Spree.t(:created_at) %><%= Spree.t(:number) %><%= Spree.t(:order) %><%= Spree.t(:reimbursement_status) %><%= Spree.t(:pre_tax_total) %>
    <%= customer_return.created_at.to_date %><%= link_to customer_return.number, spree.edit_admin_order_customer_return_path(customer_return.order, customer_return) %><%= link_to customer_return.order.number, spree.edit_admin_order_path(customer_return.order) %> + <% if customer_return.fully_reimbursed? %> + <%= Spree.t(:reimbursed) %> + <% else %> + <%= Spree.t(:incomplete) %> + <% end %> + <%= customer_return.display_pre_tax_total.to_html %> + <%= link_to_edit_url spree.edit_admin_order_customer_return_path(customer_return.order, customer_return), no_text: true if can?(:edit, customer_return) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::CustomerReturn)) %>.
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection, per_page_action: :customer_returns } %> diff --git a/backend/app/views/spree/admin/return_index/return_authorizations.html.erb b/backend/app/views/spree/admin/return_index/return_authorizations.html.erb new file mode 100644 index 00000000000..92969aff4d3 --- /dev/null +++ b/backend/app/views/spree/admin/return_index/return_authorizations.html.erb @@ -0,0 +1,76 @@ +<% content_for :page_title do %> + <%= Spree.t(:return_authorizations) %> +<% end %> + +<% content_for :page_tabs do %> +
  • "> + <%= link_to Spree.t(:all), spree.admin_return_authorizations_path %> +
  • +
  • "> + <%= link_to Spree.t(:authorized), params.merge({q: {state_eq: :authorized}}).permit! %> +
  • +
  • "> + <%= link_to Spree.t(:canceled), params.merge({q: {state_eq: :canceled}}).permit! %> +
  • +<% end %> + +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, @search], url: spree.admin_return_authorizations_path do |f| %> +
    +
    +
    + <%= f.label :number_cont, Spree.t(:number) %> + <%= f.text_field :number_cont, class: "form-control js-quick-search-target js-filterable" %> +
    +
    +
    +
    + <%= label_tag :q_state_eq, Spree.t(:status) %> + <%= f.select :state_eq, Spree::ReturnAuthorization.state_machines[:state].states.collect {|s| [Spree.t("return_authorization_states.#{s.name}"), s.value]}, {include_blank: true}, class: 'select2 js-filterable' %> +
    +
    +
    +
    + <%= button Spree.t(:search), 'search' %> +
    + <% end %> +
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection, per_page_action: :return_authorizations } %> + +<% if @collection.any? %> + + + + + + + + + + + + <% @collection.each do |return_authorization| %> + + + + + + + + <% end %> + +
    <%= Spree.t(:created_at) %><%= Spree.t(:number) %><%= Spree.t(:order) %><%= Spree.t(:status) %>
    <%= return_authorization.created_at.to_date %><%= link_to return_authorization.number, spree.edit_admin_order_return_authorization_path(return_authorization.order, return_authorization) %><%= link_to return_authorization.order.number, spree.edit_admin_order_path(return_authorization.order) %> + <%= Spree.t("return_authorization_states.#{return_authorization.state}") %> + + <%= link_to_edit return_authorization, url: spree.edit_admin_order_return_authorization_path(return_authorization.order, return_authorization), no_text: true if can?(:edit, return_authorization) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::ReturnAuthorization)) %>. +
    +<% end %> + +<%= render partial: 'spree/admin/shared/index_table_options', locals: { collection: @collection, per_page_action: :return_authorizations } %> diff --git a/backend/app/views/spree/admin/roles/_form.html.erb b/backend/app/views/spree/admin/roles/_form.html.erb new file mode 100644 index 00000000000..68b67c99f27 --- /dev/null +++ b/backend/app/views/spree/admin/roles/_form.html.erb @@ -0,0 +1,9 @@ +
    + <%= f.field_container :name, class: ["form-group"], 'data-hook' => "role_name" do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + +
    +
    diff --git a/backend/app/views/spree/admin/roles/edit.html.erb b/backend/app/views/spree/admin/roles/edit.html.erb new file mode 100644 index 00000000000..6fc77e77f15 --- /dev/null +++ b/backend/app/views/spree/admin/roles/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:roles), spree.admin_roles_url %> / + <%= @role.name.capitalize %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @role } %> + +<%= form_for [:admin, @role] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/roles/index.html.erb b/backend/app/views/spree/admin/roles/index.html.erb new file mode 100644 index 00000000000..b9ac2f2171a --- /dev/null +++ b/backend/app/views/spree/admin/roles/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :page_title do %> + <%= Spree.t(:roles) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_role), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_role_link' %> +<% end if can? :create, Spree::Role %> + +<% if @roles.any? %> + + + + + + + + + <% @roles.each do |role|%> + + + + + <% end %> + +
    <%= Spree.t(:role_id) %>
    <%= role.name %> + <%= link_to_edit(role, no_text: true) if can? :edit, role %> + <%= link_to_delete(role, no_text: true) if can? :destroy, role %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Role)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::Role %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/roles/new.html.erb b/backend/app/views/spree/admin/roles/new.html.erb new file mode 100644 index 00000000000..daba60fdfd8 --- /dev/null +++ b/backend/app/views/spree/admin/roles/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:roles), spree.admin_roles_url %> / + <%= Spree.t(:new_role) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @role } %> + +<%= form_for [:admin, @role] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/_account_nav.html.erb b/backend/app/views/spree/admin/shared/_account_nav.html.erb new file mode 100644 index 00000000000..0c8bc898bb2 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_account_nav.html.erb @@ -0,0 +1,61 @@ +<% if try_spree_current_user %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/_address.html.erb b/backend/app/views/spree/admin/shared/_address.html.erb new file mode 100644 index 00000000000..a2e7f59888c --- /dev/null +++ b/backend/app/views/spree/admin/shared/_address.html.erb @@ -0,0 +1,38 @@ +
    +
    <%= address.full_name %>
    + <% unless address.company.blank? %> +
    + <%= address.company %> +
    + <% end %> +
    +
    +
    + <%= address.address1 %> +
    + <% unless address.address2.blank? %> +
    + <%= address.address2 %> +
    + <% end %> +
    +
    + <%= address.city %> + <%= address.state_text %> + <%= address.zipcode %> +
    <%= address.country.try(:name) %>
    +
    +
    + <% unless address.phone.blank? %> +
    + <%= Spree.t(:phone) %> + <%= address.phone %> +
    + <% end %> + <% unless address.alternative_phone.blank? %> +
    + <%= Spree.t(:alternative_phone) %> + <%= address.alternative_phone %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/shared/_address_form.html.erb b/backend/app/views/spree/admin/shared/_address_form.html.erb new file mode 100644 index 00000000000..bb9e42a78db --- /dev/null +++ b/backend/app/views/spree/admin/shared/_address_form.html.erb @@ -0,0 +1,79 @@ +<% s_or_b = type.chars.first %> + +
    + <%= field_container f.object, :firstname, class: ["form-group", "#{type}-row"] do %> + <%= f.label :firstname, Spree.t(:first_name) %> + <%= f.text_field :firstname, class: 'form-control' %> + <%= error_message_on f.object, :firstname %> + <% end %> + + <%= field_container f.object, :lastname, class: ["form-group", "#{type}-row"] do %> + <%= f.label :lastname, Spree.t(:last_name) %> + <%= f.text_field :lastname, class: 'form-control' %> + <%= error_message_on f.object, :lastname %> + <% end %> + + <% if Spree::Config[:company] %> + <%= field_container f.object, :company, class: ["form-group", "#{type}-row"] do %> + <%= f.label :company, Spree.t(:company) %> + <%= f.text_field :company, class: 'form-control' %> + <%= error_message_on f.object, :company %> + <% end %> + <% end %> + + <%= field_container f.object, :address1, class: ["form-group", "#{type}-row"] do %> + <%= f.label :address1, Spree.t(:street_address) %> + <%= f.text_field :address1, class: 'form-control' %> + <%= error_message_on f.object, :address1 %> + <% end %> + + <%= field_container f.object, :address2, class: ["form-group", "#{type}-row"] do %> + <%= f.label :address2, Spree.t(:street_address_2) %> + <%= f.text_field :address2, class: 'form-control' %> + <%= error_message_on f.object, :address2 %> + <% end %> + + <%= field_container f.object, :city, class: ["form-group", "#{type}-row"] do %> + <%= f.label :city, Spree.t(:city) %> + <%= f.text_field :city, class: 'form-control' %> + <%= error_message_on f.object, :city %> + <% end %> + + <%= field_container f.object, :zipcode, class: ["form-group", "#{type}-row"] do %> + <%= f.label :zipcode, Spree.t(:zip) %> + <%= f.text_field :zipcode, class: 'form-control' %> + <%= error_message_on f.object, :zipcode %> + <% end %> + +
    "> + <%= f.label :country_id, Spree.t(:country) %> + + <%= f.collection_select :country_id, available_countries, :id, :name, {}, { class: 'select2' } %> + +
    + + <%= field_container f.object, :state, class: ["form-group", "#{type}-row"] do %> + <%= f.label :state_id, Spree.t(:state) %> + + <%= f.text_field :state_name, + style: "display: #{f.object.country.states.empty? ? 'block' : 'none' };", + disabled: !f.object.country.states.empty?, class: 'form-control state_name' %> + <%= f.collection_select :state_id, f.object.country.states.sort, :id, :name, { include_blank: true }, { class: 'select2', style: "display: #{f.object.country.states.empty? ? 'none' : 'block' };", disabled: f.object.country.states.empty? } %> + + <%= error_message_on f.object, :state_id %> + <% end %> + + <%= field_container f.object, :phone, class: ["form-group", "#{type}-row"] do %> + <%= f.label :phone, Spree.t(:phone) %> + <%= f.phone_field :phone, class: 'form-control' %> + <%= error_message_on f.object, :phone %> + <% end %> +
    + +<% content_for :head do %> + <%= javascript_tag do %> + $(document).ready(function(){ + $('span#<%= s_or_b %>country .select2').on('change', function() { update_state('<%= s_or_b %>'); }); + }); + <% end %> +<% end %> diff --git a/backend/app/views/spree/admin/shared/_calculator_fields.html.erb b/backend/app/views/spree/admin/shared/_calculator_fields.html.erb new file mode 100644 index 00000000000..bf23d131840 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_calculator_fields.html.erb @@ -0,0 +1,27 @@ +
    + +
    +

    + <%= Spree.t(:calculator) %> +

    +
    + +
    +
    + <%= f.label(:calculator_type, Spree.t(:calculator), for: 'calc_type') %> + <%= f.select(:calculator_type, @calculators.map { |c| [c.description, c.name] }, {}, {id: 'calc_type', class: 'select2'}) %> +
    + <% if !@object.new_record? %> +
    +
    + <%= f.fields_for :calculator do |calculator_form| %> + <%= preference_fields @object.calculator, calculator_form %> + <% end %> +
    + <% if @object.calculator.respond_to?(:preferences) %> + <%= Spree.t(:calculator_settings_warning) %> + <% end %> +
    + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/shared/_content_header.html.erb b/backend/app/views/spree/admin/shared/_content_header.html.erb new file mode 100644 index 00000000000..51309c4aa44 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_content_header.html.erb @@ -0,0 +1,26 @@ +<% if content_for?(:page_title) || content_for?(:page_actions) %> +
    page-header"> +
    + <% if content_for?(:page_title) %> +

    + <%= yield :page_title %> + <% if content_for?(:page_title_small) %> + <%= yield :page_title_small %> + <% end %> +

    + <% end %> + + <% if content_for?(:page_actions) %> +
    + <%= yield :page_actions %> +
    + <% end %> + + <% if content_for?(:page_tabs) %> + + <% end %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/_destroy.js.erb b/backend/app/views/spree/admin/shared/_destroy.js.erb new file mode 100644 index 00000000000..6c92af395fb --- /dev/null +++ b/backend/app/views/spree/admin/shared/_destroy.js.erb @@ -0,0 +1,10 @@ +<% if success = flash.discard(:success) %> + show_flash('success', "<%= j success %>"); +<% end %> + +<% if error = flash.discard(:error) %> + show_flash('error', "<%= j error %>"); +<% end %> + +<%= render partial: '/spree/admin/shared/update_order_state' if @order %> +<%= render partial: '/spree/admin/shared/update_store_credit' if @store_credit %> diff --git a/backend/app/views/spree/admin/shared/_edit_resource_links.html.erb b/backend/app/views/spree/admin/shared/_edit_resource_links.html.erb new file mode 100644 index 00000000000..508e84e5775 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_edit_resource_links.html.erb @@ -0,0 +1,5 @@ +
    + <%= button Spree.t('actions.update'), 'refresh', 'submit', {class: 'btn-success', data: { disable_with: "#{ Spree.t(:saving) }..." }} %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), collection_url, icon: 'delete' %> +
    diff --git a/backend/app/views/spree/admin/shared/_error_messages.html.erb b/backend/app/views/spree/admin/shared/_error_messages.html.erb new file mode 100644 index 00000000000..f314f1ba6df --- /dev/null +++ b/backend/app/views/spree/admin/shared/_error_messages.html.erb @@ -0,0 +1,12 @@ +<% if target && target.errors.any? %> +
    +

    + <%= Spree.t(:errors_prohibited_this_record_from_being_saved, count: target.errors.size) %>:
    +

    +
      + <% target.errors.full_messages.each do |msg| %> +
    • <%= msg %>
    • + <% end %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/_head.html.erb b/backend/app/views/spree/admin/shared/_head.html.erb new file mode 100644 index 00000000000..02b9de23a40 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_head.html.erb @@ -0,0 +1,27 @@ + + + + +<%= csrf_meta_tags %> + + + <% if content_for? :title %> + <%= yield :title %> + <% else %> + <%= "Spree #{Spree.t('administration')}: " %> + <%= Spree.t(controller.controller_name, default: controller.controller_name.titleize) %> + <% end %> + + +<%= stylesheet_link_tag 'spree/backend/all', media: :all %> + +<%= render 'spree/shared/paths' %> +<%= javascript_include_tag 'spree/backend/all' %> +<%= render "spree/admin/shared/translations" %> + +<%= javascript_tag do %> + <%== "var AUTH_TOKEN = #{form_authenticity_token.inspect};" %> + <%== "Spree.api_key = '#{try_spree_current_user.spree_api_key}';" if try_spree_current_user %> +<% end %> + +<%= yield :head %> diff --git a/backend/app/views/spree/admin/shared/_header.html.erb b/backend/app/views/spree/admin/shared/_header.html.erb new file mode 100644 index 00000000000..1d55ef7462c --- /dev/null +++ b/backend/app/views/spree/admin/shared/_header.html.erb @@ -0,0 +1,27 @@ +
    + + + +
    diff --git a/backend/app/views/spree/admin/shared/_index_table_options.html.erb b/backend/app/views/spree/admin/shared/_index_table_options.html.erb new file mode 100644 index 00000000000..c1946e80d66 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_index_table_options.html.erb @@ -0,0 +1,19 @@ +<% args = params.clone %> + +<% if defined?(per_page_action) %> + <% args[:action] = per_page_action %> +<% end %> + +
    +
    + <%= paginate collection %> +
    +
    +
    + <%= form_tag(per_page_dropdown_params(args), { method: :get, class: 'js-per-page-form form-inline' }) do %> + <%= per_page_dropdown %> + <% end %> +
    +
    +
    +
    diff --git a/backend/app/views/spree/admin/shared/_main_menu.html.erb b/backend/app/views/spree/admin/shared/_main_menu.html.erb new file mode 100644 index 00000000000..bd93e8301e4 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_main_menu.html.erb @@ -0,0 +1,41 @@ +<% if can? :admin, Spree::Order %> + +<% end %> + +<% if can?(:admin, Spree::ReturnAuthorization) || can?(:admin, Spree::CustomerReturn) %> + +<% end %> + +<% if can? :admin, Spree::Product %> + +<% end %> + +<% if can? :admin, Spree::Admin::ReportsController %> + +<% end %> + +<% if can? :admin, Spree::Promotion %> + +<% end %> + +<% if Spree.user_class && can?(:admin, Spree.user_class) %> + +<% end %> + +<% if can? :admin, current_store %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/_new_resource_links.html.erb b/backend/app/views/spree/admin/shared/_new_resource_links.html.erb new file mode 100644 index 00000000000..137801858ac --- /dev/null +++ b/backend/app/views/spree/admin/shared/_new_resource_links.html.erb @@ -0,0 +1,5 @@ +
    + <%= button Spree.t('actions.create'), 'ok', 'submit', {class: 'btn-success', data: { disable_with: "#{ Spree.t(:saving) }..." }} %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), collection_url, icon: 'remove' %> +
    diff --git a/backend/app/views/spree/admin/shared/_order_summary.html.erb b/backend/app/views/spree/admin/shared/_order_summary.html.erb new file mode 100644 index 00000000000..d3327b40648 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_order_summary.html.erb @@ -0,0 +1,127 @@ +
    +
    +

    <%= Spree.t(:summary) %>

    +
    + + + + + + + + + + + + + + + + + <% if @order.checkout_steps.include?("delivery") && @order.ship_total > 0 %> + + + + + <% end %> + + <% if @order.included_tax_total != 0 %> + + + + + <% end %> + + <% if @order.additional_tax_total != 0 %> + + + + + <% end %> + + + + + + + <% if @order.completed? %> + + + + + + + + + + + + + <% end %> + + <% if @order.approved? %> + + + + + + + + + <% end %> + + <% if @order.canceled? && @order.canceler && @order.canceled_at %> + + + + + + + + + <% end %> + +
    + <%= Spree.t(:status) %>: + + + <%= Spree.t(@order.state, scope: :order_state) %> + +
    + <%= Spree.t(:channel) %>: + <%= @order.channel %>
    + <%= Spree.t(:subtotal) %>: + + <%= @order.display_item_total.to_html %> +
    + <%= Spree.t(:ship_total) %>: + + <%= @order.display_ship_total.to_html %> +
    + <%= Spree.t(:tax_included) %>: + + <%= @order.display_included_tax_total.to_html %> +
    + <%= Spree.t(:tax) %>: + + <%= @order.display_additional_tax_total.to_html %> +
    + <%= Spree.t(:total) %>: + <%= @order.display_total.to_html %>
    + <%= Spree.t(:shipment) %>: + + + <%= Spree.t(@order.shipment_state, scope: :shipment_states, default: [:missing, "none"]) %> + +
    + <%= Spree.t(:payment) %>: + + + <%= Spree.t(@order.payment_state, scope: :payment_states, default: [:missing, "none"]) %> + +
    + <%= Spree.t(:date_completed) %>: + + <%= pretty_time(@order.completed_at) %> +
    <%= Spree.t(:approver) %><%= @order.approver.try(:email) %>
    <%= Spree.t(:approved_at) %><%= pretty_time(@order.approved_at) %>
    <%= Spree.t(:canceler) %><%= @order.canceler.email %>
    <%= Spree.t(:canceled_at) %><%= pretty_time(@order.canceled_at) %>
    +
    diff --git a/backend/app/views/spree/admin/shared/_order_tabs.html.erb b/backend/app/views/spree/admin/shared/_order_tabs.html.erb new file mode 100644 index 00000000000..c304c0bae64 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_order_tabs.html.erb @@ -0,0 +1,66 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:orders), admin_orders_path %> / + <%= link_to @order.number, spree.edit_admin_order_path(@order) %> +<% end %> + +<% content_for :sidebar do %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/_product_tabs.html.erb b/backend/app/views/spree/admin/shared/_product_tabs.html.erb new file mode 100644 index 00000000000..40a431b2fdf --- /dev/null +++ b/backend/app/views/spree/admin/shared/_product_tabs.html.erb @@ -0,0 +1,24 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:products), admin_products_path %> / + <%= @product.name %> +<% end %> + +<% content_for :sidebar do %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/_refunds.html.erb b/backend/app/views/spree/admin/shared/_refunds.html.erb new file mode 100644 index 00000000000..83841d3a1dc --- /dev/null +++ b/backend/app/views/spree/admin/shared/_refunds.html.erb @@ -0,0 +1,32 @@ + + + + + + + + + + <% if show_actions %> + + <% end %> + + + + <% refunds.each do |refund| %> + + + + + + + + <% if show_actions %> + + <% end %> + + <% end %> + +
    <%= "#{Spree.t('date')}/#{Spree.t('time')}" %><%= Spree.t(:payment_identifier) %><%= Spree.t(:amount) %><%= Spree.t(:payment_method) %><%= Spree.t(:transaction_id) %><%= Spree.t(:reason) %>
    <%= pretty_time(refund.created_at) %><%= refund.payment.number %><%= refund.display_amount %><%= payment_method_name(refund.payment) %><%= refund.transaction_id %><%= truncate(refund.reason.name, length: 100) %> + <%= link_to_with_icon('edit', Spree.t(:edit), edit_admin_order_payment_refund_path(refund.payment.order, refund.payment, refund), no_text: true, class: "btn btn-default btn-sm") if can?(:edit, refund) %> +
    diff --git a/backend/app/views/spree/admin/shared/_report_order_criteria.html.erb b/backend/app/views/spree/admin/shared/_report_order_criteria.html.erb new file mode 100644 index 00000000000..56e672f61e5 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_report_order_criteria.html.erb @@ -0,0 +1,17 @@ +<%= search_form_for @search, url: spree.sales_total_admin_reports_path do |s| %> +
    + <%= label_tag nil, Spree.t(:date_range) %> +
    +
    + <%= s.text_field :completed_at_gt, class: 'datepicker datepicker-from form-control', value: datepicker_field_value(params[:q][:completed_at_gt]) %> +
    +
    + <%= s.text_field :completed_at_lt, class: 'datepicker datepicker-to form-control', value: datepicker_field_value(params[:q][:completed_at_lt]) %> +
    +
    +
    + +
    + <%= button Spree.t(:search), 'search' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/_sidebar.html.erb b/backend/app/views/spree/admin/shared/_sidebar.html.erb new file mode 100644 index 00000000000..51645e026c2 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_sidebar.html.erb @@ -0,0 +1,5 @@ +<% if content_for?(:sidebar) %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/_table_filter.html.erb b/backend/app/views/spree/admin/shared/_table_filter.html.erb new file mode 100644 index 00000000000..3807f1dc054 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_table_filter.html.erb @@ -0,0 +1,24 @@ +<% if content_for?(:table_filter) %> +
    +
    +
    + + + + <%= form_tag request.fullpath, id: "quick-search" do %> + <%= text_field_tag :quick_search, nil, class: "form-control js-quick-search", placeholder: Spree.t(:quick_search) %> + <% end %> +
    + <% unless defined?(simple) %> +
    + <%= yield :table_filter %> +
    + <% end %> +
    +
    + +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/_translations.html.erb b/backend/app/views/spree/admin/shared/_translations.html.erb new file mode 100644 index 00000000000..3b8b7142b49 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_translations.html.erb @@ -0,0 +1,49 @@ + + +<% if I18n.locale != :en %> + <%= javascript_include_tag "select2_locale_#{I18n.locale}" %> +<% end %> diff --git a/backend/app/views/spree/admin/shared/_update_order_state.js.erb b/backend/app/views/spree/admin/shared/_update_order_state.js.erb new file mode 100644 index 00000000000..d6e32802441 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_update_order_state.js.erb @@ -0,0 +1,7 @@ +$('#order_tab_summary h5#order_status').html('<%= j Spree.t(:status) %>: <%= j Spree.t(@order.state, scope: :order_state) %>'); +$('#order_tab_summary h5#order_total').html('<%= j Spree.t(:total) %>: <%= j @order.display_total.to_html %>'); + +<% if @order.completed? %> + $('#order_tab_summary h5#payment_status').html('<%= j Spree.t(:payment) %>: <%= j Spree.t(@order.payment_state, scope: :payment_states, default: [:missing, "none"]) %>'); + $('#order_tab_summary h5#shipment_status').html('<%= j Spree.t(:shipment) %>: <%= j Spree.t(@order.shipment_state, scope: :shipment_state, default: [:missing, "none"]) %>'); +<% end %> diff --git a/backend/app/views/spree/admin/shared/_update_store_credit.js.erb b/backend/app/views/spree/admin/shared/_update_store_credit.js.erb new file mode 100644 index 00000000000..2cf98ac354b --- /dev/null +++ b/backend/app/views/spree/admin/shared/_update_store_credit.js.erb @@ -0,0 +1 @@ +$('#user-lifetime-stats #store_credit').html('<%= Spree::Money.new(@user.total_available_store_credit) %>'); diff --git a/backend/app/views/spree/admin/shared/_version.html.erb b/backend/app/views/spree/admin/shared/_version.html.erb new file mode 100644 index 00000000000..1042ea252c9 --- /dev/null +++ b/backend/app/views/spree/admin/shared/_version.html.erb @@ -0,0 +1,5 @@ +<% if can?(:admin, current_store) && Spree::Config[:admin_show_version] %> + +<% end %> diff --git a/backend/app/views/spree/admin/shared/named_types/_edit.html.erb b/backend/app/views/spree/admin/shared/named_types/_edit.html.erb new file mode 100644 index 00000000000..eb7aa10f626 --- /dev/null +++ b/backend/app/views/spree/admin/shared/named_types/_edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to collection_url_label, collection_url %> / + <%= @object.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @object } %> + +<%= form_for [:admin, @object] do |f| %> +
    + <%= render partial: 'spree/admin/shared/named_types/form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/named_types/_form.html.erb b/backend/app/views/spree/admin/shared/named_types/_form.html.erb new file mode 100644 index 00000000000..bbbf7f96238 --- /dev/null +++ b/backend/app/views/spree/admin/shared/named_types/_form.html.erb @@ -0,0 +1,13 @@ +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    + <%= label_tag :active do %> + <%= f.check_box :active %> + <%= Spree.t(:active) %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/shared/named_types/_index.html.erb b/backend/app/views/spree/admin/shared/named_types/_index.html.erb new file mode 100644 index 00000000000..09b678a2165 --- /dev/null +++ b/backend/app/views/spree/admin/shared/named_types/_index.html.erb @@ -0,0 +1,42 @@ +<% content_for :page_title do %> + <%= page_title %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to new_button_text, new_object_url, { icon: 'add', id: 'admin_new_named_type', class: "btn-success" } %> +<% end if can? :create, resource %> + +<% if @collection.any? %> + + + + + + + + + + <% @collection.each do |named_type| %> + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:status) %>
    + <%= named_type.name %> + + <%= Spree.t(named_type.active? ? :active : :inactive) %> + + <% if named_type.mutable? %> + <%= link_to_edit(named_type, no_text: true) if can? :edit, named_type %> + <%= link_to_delete(named_type, no_text: true) if can? :delete, named_type %> + <% end %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: resource_name) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, resource %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/named_types/_new.html.erb b/backend/app/views/spree/admin/shared/named_types/_new.html.erb new file mode 100644 index 00000000000..ae7e8205f10 --- /dev/null +++ b/backend/app/views/spree/admin/shared/named_types/_new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to collection_url_label, collection_url %> / + <%= page_title %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @object } %> + +<%= form_for [:admin, @object] do |f| %> +
    + <%= render partial: 'spree/admin/shared/named_types/form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/shared/sub_menu/_configuration.html.erb b/backend/app/views/spree/admin/shared/sub_menu/_configuration.html.erb new file mode 100644 index 00000000000..e0d40857749 --- /dev/null +++ b/backend/app/views/spree/admin/shared/sub_menu/_configuration.html.erb @@ -0,0 +1,23 @@ + diff --git a/backend/app/views/spree/admin/shared/sub_menu/_product.html.erb b/backend/app/views/spree/admin/shared/sub_menu/_product.html.erb new file mode 100644 index 00000000000..d057412e276 --- /dev/null +++ b/backend/app/views/spree/admin/shared/sub_menu/_product.html.erb @@ -0,0 +1,8 @@ + diff --git a/backend/app/views/spree/admin/shared/sub_menu/_promotion.html.erb b/backend/app/views/spree/admin/shared/sub_menu/_promotion.html.erb new file mode 100644 index 00000000000..851ea0c4c8e --- /dev/null +++ b/backend/app/views/spree/admin/shared/sub_menu/_promotion.html.erb @@ -0,0 +1,4 @@ + diff --git a/backend/app/views/spree/admin/shared/sub_menu/_returns.html.erb b/backend/app/views/spree/admin/shared/sub_menu/_returns.html.erb new file mode 100644 index 00000000000..e54f327785a --- /dev/null +++ b/backend/app/views/spree/admin/shared/sub_menu/_returns.html.erb @@ -0,0 +1,4 @@ + diff --git a/backend/app/views/spree/admin/shipping_categories/_form.html.erb b/backend/app/views/spree/admin/shipping_categories/_form.html.erb new file mode 100644 index 00000000000..34cc719253d --- /dev/null +++ b/backend/app/views/spree/admin/shipping_categories/_form.html.erb @@ -0,0 +1,9 @@ +
    +
    + <%= f.field_container :name, class: ["form-group"] do %> + <%= f.label :name, Spree.t(:name) %>
    + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/shipping_categories/edit.html.erb b/backend/app/views/spree/admin/shipping_categories/edit.html.erb new file mode 100644 index 00000000000..1182cd907a7 --- /dev/null +++ b/backend/app/views/spree/admin/shipping_categories/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:shipping_categories), spree.admin_shipping_categories_url %> / + <%= @shipping_category.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @shipping_category } %> + +<%= form_for [:admin, @shipping_category] do |f| %> + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/admin/shipping_categories/index.html.erb b/backend/app/views/spree/admin/shipping_categories/index.html.erb new file mode 100644 index 00000000000..3f3f661a9d7 --- /dev/null +++ b/backend/app/views/spree/admin/shipping_categories/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::ShippingCategory) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_shipping_category), new_object_url, class: "btn-success", icon: 'add' %> +<% end if can? :create, Spree::ShippingCategory %> + +<% if @shipping_categories.any? %> + + + + + + + + + <% @shipping_categories.each do |shipping_category|%> + + + + + <% end %> + +
    <%= Spree.t(:name) %>
    <%= shipping_category.name %> + <%= link_to_edit(shipping_category, no_text: true) if can? :edit, shipping_category %> + <%= link_to_delete(shipping_category, no_text: true) if can? :edit, shipping_category %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::ShippingCategory)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::ShippingCategory %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/shipping_categories/new.html.erb b/backend/app/views/spree/admin/shipping_categories/new.html.erb new file mode 100644 index 00000000000..33ebae42e5a --- /dev/null +++ b/backend/app/views/spree/admin/shipping_categories/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:shipping_categories), spree.admin_shipping_categories_url %> / + <%= Spree.t(:new_shipping_category) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @shipping_category } %> + +<%= form_for [:admin, @shipping_category] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/shipping_methods/_form.html.erb b/backend/app/views/spree/admin/shipping_methods/_form.html.erb new file mode 100644 index 00000000000..cd330ce49ef --- /dev/null +++ b/backend/app/views/spree/admin/shipping_methods/_form.html.erb @@ -0,0 +1,119 @@ +
    +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    + +
    + <%= f.field_container :display_on, class: ['form-group'] do %> + <%= f.label :display_on, Spree.t(:display) %> + <%= select(:shipping_method, :display_on, Spree::ShippingMethod::DISPLAY.collect { |display| [Spree.t(display), display.to_s] }, { include_blank: true }, { class: 'select2' }) %> + <%= f.error_message_on :display_on %> + <% end %> +
    +
    + +
    +
    + <%= f.field_container :admin_name, class: ['form-group'] do %> + <%= f.label :admin_name, Spree.t(:internal_name) %> + <%= f.text_field :admin_name, class: 'form-control', label: false %> + <%= f.error_message_on :admin_name %> + <% end %> +
    + +
    + <%= f.field_container :code, class: ['form-group'] do %> + <%= f.label :code, Spree.t(:code) %> + <%= f.text_field :code, class: 'form-control', label: false %> + <%= f.error_message_on :code %> + <% end %> +
    + +
    + <%= f.field_container :tracking_url, class: ['form-group'] do %> + <%= f.label :tracking_url, Spree.t(:tracking_url) %> + <%= f.text_field :tracking_url, class: 'form-control', placeholder: Spree.t(:tracking_url_placeholder) %> + <%= f.error_message_on :tracking_url %> + <% end %> +
    +
    +
    + +
    +
    +
    +
    +

    + <%= Spree.t(:shipping_categories) %> +

    +
    + +
    + <%= f.field_container :categories, class: ['form-group'] do %> + <% Spree::ShippingCategory.all.each do |category| %> +
    + <%= label_tag do %> + <%= check_box_tag('shipping_method[shipping_categories][]', category.id, @shipping_method.shipping_categories.include?(category)) %> + <%= category.name %> + <% end %> +
    + <% end %> + <%= f.error_message_on :shipping_category_id %> + <% end %> +
    +
    +
    + +
    +
    +
    +

    + <%= Spree.t(:zones) %> +

    +
    + +
    + <%= f.field_container :zones, class: ['form-group'] do %> + <% shipping_method_zones = @shipping_method.zones.to_a %> + <% Spree::Zone.all.each do |zone| %> +
    + <%= label_tag do %> + <%= check_box_tag('shipping_method[zones][]', zone.id, shipping_method_zones.include?(zone)) %> + <%= zone.name %> + <% end %> +
    + <% end %> + <%= f.error_message_on :zone_id %> + <% end %> +
    +
    +
    +
    + +
    +
    + <%= render partial: 'spree/admin/shared/calculator_fields', locals: { f: f } %> +
    + +
    +
    +
    +

    + <%= Spree.t(:tax_category) %> +

    +
    + +
    + <%= f.field_container :categories, class: ['form-group'] do %> + <%= f.select :tax_category_id, @tax_categories.map { |tc| [tc.name, tc.id] }, { include_blank: true }, class: "select2" %> + <%= f.error_message_on :tax_category_id %> + <% end %> +
    +
    +
    +
    diff --git a/backend/app/views/spree/admin/shipping_methods/edit.html.erb b/backend/app/views/spree/admin/shipping_methods/edit.html.erb new file mode 100644 index 00000000000..6b48a692218 --- /dev/null +++ b/backend/app/views/spree/admin/shipping_methods/edit.html.erb @@ -0,0 +1,17 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:shipping_methods), spree.admin_shipping_methods_url %> / + <%= @shipping_method.name %> +<% end %> + +
    + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @shipping_method } %> +
    + +
    + <%= form_for [:admin, @shipping_method] do |f| %> + <%= render partial: 'form', locals: { f: f } %> +
    + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/shipping_methods/index.html.erb b/backend/app/views/spree/admin/shipping_methods/index.html.erb new file mode 100644 index 00000000000..74271405f8e --- /dev/null +++ b/backend/app/views/spree/admin/shipping_methods/index.html.erb @@ -0,0 +1,40 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::ShippingMethod) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_shipping_method), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_shipping_method_link' %> +<% end if can? :create, Spree::ShippingMethod %> + +<% if @shipping_methods.any? %> + + + + + + + + + + + + <% @shipping_methods.includes(:zones, :calculator).each do |shipping_method|%> + + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:zone) %><%= Spree.t(:calculator) %><%= Spree.t(:display) %>
    <%= shipping_method.admin_name + ' / ' if shipping_method.admin_name.present? %><%= shipping_method.name %><%= shipping_method.zones.collect(&:name).join(", ") if shipping_method.zones %><%= shipping_method.calculator.description %><%= shipping_method.display_on.blank? ? Spree.t(:none) : Spree.t(shipping_method.display_on) %> + <%= link_to_edit(shipping_method, no_text: true) if can? :edit, shipping_method %> + <%= link_to_delete(shipping_method, no_text: true) if can? :delete, shipping_method %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::ShippingMethod)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::PaymentMethod %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/shipping_methods/new.html.erb b/backend/app/views/spree/admin/shipping_methods/new.html.erb new file mode 100644 index 00000000000..86128aaeef7 --- /dev/null +++ b/backend/app/views/spree/admin/shipping_methods/new.html.erb @@ -0,0 +1,20 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:shipping_methods), spree.admin_shipping_methods_url %> / + <%= Spree.t(:new_shipping_method) %> +<% end %> + +
    + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @shipping_method } %> +
    + +
    + <%= form_for [:admin, @shipping_method] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + +
    + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +
    + <% end %> +
    diff --git a/backend/app/views/spree/admin/state_changes/index.html.erb b/backend/app/views/spree/admin/state_changes/index.html.erb new file mode 100644 index 00000000000..a2784384547 --- /dev/null +++ b/backend/app/views/spree/admin/state_changes/index.html.erb @@ -0,0 +1,45 @@ +<%= render partial: 'spree/admin/shared/order_tabs', locals: { current: :state_changes } %> + +<% content_for :page_title do %> + / <%= plural_resource_name(Spree::StateChange) %> +<% end %> + +<% if @state_changes.any? %> + + + + + + + + + + + + + <% @state_changes.each do |state_change| %> + + + + + + + + <% end %> + +
    <%= Spree::StateChange.human_attribute_name(:type) %><%= Spree::StateChange.human_attribute_name(:state_from) %><%= Spree::StateChange.human_attribute_name(:state_to) %><%= Spree::StateChange.human_attribute_name(:user) %><%= Spree::StateChange.human_attribute_name(:timestamp) %>
    <%= Spree.t("state_machine_states.#{state_change.name}") %><%= state_change.previous_state ? Spree.t(state_change.previous_state, scope: "#{ state_change.name }_state") : Spree.t(:previous_state_missing) %><%= Spree.t(state_change.next_state, scope: "#{ state_change.name }_state") %> + <% if state_change.user %> + <% user_login = state_change.user.try(:login) || state_change.user.try(:email) %> + <%= link_to user_login, spree.admin_user_path(state_change.user) %> + <% end %> + + <%= pretty_time(state_change.created_at) %> + <% if state_change.created_at != state_change.updated_at %> + <%= Spree::StateChange.human_attribute_name(:updated) %>: <%= pretty_time(state_change.updated_at) %> + <% end %> +
    +<% else %> +
    + <%= Spree.t(:no_state_changes) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/states/_form.html.erb b/backend/app/views/spree/admin/states/_form.html.erb new file mode 100644 index 00000000000..753dd5c6d45 --- /dev/null +++ b/backend/app/views/spree/admin/states/_form.html.erb @@ -0,0 +1,15 @@ +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> +
    +
    + <%= f.field_container :abbr, class: ['form-group'] do %> + <%= f.label :abbr, Spree.t(:abbreviation) %> + <%= f.text_field :abbr, class: 'form-control' %> + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/states/_state_list.html.erb b/backend/app/views/spree/admin/states/_state_list.html.erb new file mode 100644 index 00000000000..95002cba5dc --- /dev/null +++ b/backend/app/views/spree/admin/states/_state_list.html.erb @@ -0,0 +1,26 @@ +
    + + + + + + + + + + + <% @states.each do |state| %> + + + + + + <% end %> + <% if @states.empty? %> + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:abbreviation) %>
    <%= state.name %><%= state.abbr %> + <%= link_to_edit(state, no_text: true) if can? :edit, state %> + <%= link_to_delete(state, no_text: true) if can? :delete, state %> +
    <%= Spree.t(:none) %>
    diff --git a/backend/app/views/spree/admin/states/edit.html.erb b/backend/app/views/spree/admin/states/edit.html.erb new file mode 100644 index 00000000000..c9b490dc188 --- /dev/null +++ b/backend/app/views/spree/admin/states/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:states), spree.admin_country_states_url(@country) %> / + <%= @state.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @state } %> + +<%= form_for [:admin, @country, @state] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/states/index.html.erb b/backend/app/views/spree/admin/states/index.html.erb new file mode 100644 index 00000000000..9233cc5dc5f --- /dev/null +++ b/backend/app/views/spree/admin/states/index.html.erb @@ -0,0 +1,18 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::State) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_state), new_admin_country_state_url(@country), { class: "btn-success", icon: 'add', id: 'new_state_link' } %> +<% end if can? :create, Spree::State %> + +
    + <%= label_tag :country, Spree.t(:country) %> + +
    + +
    + <%= render partial: 'state_list'%> +
    diff --git a/backend/app/views/spree/admin/states/new.html.erb b/backend/app/views/spree/admin/states/new.html.erb new file mode 100644 index 00000000000..a3372dace53 --- /dev/null +++ b/backend/app/views/spree/admin/states/new.html.erb @@ -0,0 +1,13 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @state } %> + +<% content_for :page_title do %> + <%= link_to Spree.t(:states), spree.admin_country_states_url(@country) %> / + <%= Spree.t(:new_state) %> +<% end %> + +<%= form_for [:admin, @country, @state] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/states/new.js.erb b/backend/app/views/spree/admin/states/new.js.erb new file mode 100644 index 00000000000..89e5cc4ea92 --- /dev/null +++ b/backend/app/views/spree/admin/states/new.js.erb @@ -0,0 +1,2 @@ +$("#new_state").html("<%= escape_javascript(render template: 'spree/admin/states/new', formats: [:html], handlers: [:erb]) %>"); +$("#new_state_link").hide(); diff --git a/backend/app/views/spree/admin/stock_items/destroy.js.erb b/backend/app/views/spree/admin/stock_items/destroy.js.erb new file mode 100644 index 00000000000..e3c6c133fdf --- /dev/null +++ b/backend/app/views/spree/admin/stock_items/destroy.js.erb @@ -0,0 +1 @@ +$("#stock-item-<%= @stock_item.id %>").fadeOut(); diff --git a/backend/app/views/spree/admin/stock_locations/_form.html.erb b/backend/app/views/spree/admin/stock_locations/_form.html.erb new file mode 100644 index 00000000000..4d3b8da259d --- /dev/null +++ b/backend/app/views/spree/admin/stock_locations/_form.html.erb @@ -0,0 +1,87 @@ +
    +
    +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> *
    + <%= f.text_field :name, class: 'form-control', required: true %> + <%= f.error_message_on :name %> + <% end %> +
    +
    + <%= f.field_container :admin_name, class: ['form-group'] do %> + <%= f.label :admin_name, Spree.t(:internal_name) %> + <%= f.text_field :admin_name, class: 'form-control', label: false %> + <% end %> +
    +
    + +
    + <%= Spree.t(:status) %> +
    + <%= label_tag :active do %> + <%= f.check_box :active %> + <%= Spree.t(:active) %> + <% end %> +
    +
    + <%= label_tag :default do %> + <%= f.check_box :default %> + <%= Spree.t(:default) %> + <% end %> +
    +
    + <%= label_tag :backorderable_default do %> + <%= f.check_box :backorderable_default %> + <%= Spree.t(:backorderable_default) %> + <% end %> +
    +
    + <%= label_tag :propagate_all_variants do %> + <%= f.check_box :propagate_all_variants %> + <%= Spree.t(:propagate_all_variants) %> + <% end %> +
    +
    +
    + +
    + <%= f.label :address1, Spree.t(:street_address) %> + <%= f.text_field :address1, class: 'form-control' %> +
    + +
    + <%= f.label :city, Spree.t(:city) %> + <%= f.text_field :city, class: 'form-control' %> +
    + +
    + <%= f.label :address2, Spree.t(:street_address_2) %> + <%= f.text_field :address2, class: 'form-control' %> +
    + +
    + <%= f.label :zipcode, Spree.t(:zip) %> + <%= f.text_field :zipcode, class: 'form-control' %> +
    + +
    + <%= f.label :phone, Spree.t(:phone) %> + <%= f.phone_field :phone, class: 'form-control' %> +
    + +
    + <%= f.label :country_id, Spree.t(:country) %> + <%= f.collection_select :country_id, available_countries, :id, :name, {}, { class: 'select2' } %> +
    + +
    + <% if f.object.country %> + <%= f.label :state_id, Spree.t(:state) %> + + <%= f.text_field :state_name, style: "#{f.object.country.states.empty? ? '' : 'display: none;'}", disabled: !f.object.country.states.empty?, class: 'state_name form-control' %> + <%= f.collection_select :state_id, f.object.country.states.sort, :id, :name, { include_blank: true }, {class: 'select2', style: "#{f.object.country.states.empty? ? 'display: none;' : '' };", disabled: f.object.country.states.empty?} %> + + <% end %> +
    +
    diff --git a/backend/app/views/spree/admin/stock_locations/_transfer_stock_form.html.erb b/backend/app/views/spree/admin/stock_locations/_transfer_stock_form.html.erb new file mode 100644 index 00000000000..f19ac7ad4a9 --- /dev/null +++ b/backend/app/views/spree/admin/stock_locations/_transfer_stock_form.html.erb @@ -0,0 +1,39 @@ +<%= form_tag transfer_stock_admin_stock_locations_path do %> +
    + <%= Spree.t(:move_stock_between_locations)%> +
    +
    +
    + <%= label_tag :stock_location_from_id, Spree.t(:transfer_from_location) %> + <%= select_tag :stock_location_from_id, options_from_collection_for_select(@stock_locations, :id, :name), class: 'select2' %> +
    +
    +
    +
    + <%= label_tag :stock_location_to_id, Spree.t(:transfer_to_location) %> + <%= select_tag :stock_location_to_id, options_from_collection_for_select(@stock_locations, :id, :name), class: 'select2' %> +
    +
    + +
    +
    + <%= label_tag 'variant_id', Spree.t(:variant) %> + <%= select_tag :variant_id, options_from_collection_for_select(@variants, :id, :name_and_sku), class: 'select2' %> +
    +
    + +
    +
    + <%= label_tag 'quantity', Spree.t(:quantity) %> + <%= number_field_tag :quantity, 1, class: 'form-control' %> +
    +
    +
    + +
    + <%= button Spree.t(:transfer_stock), 'plus' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), collection_url, icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_locations/edit.html.erb b/backend/app/views/spree/admin/stock_locations/edit.html.erb new file mode 100644 index 00000000000..e89acd727dc --- /dev/null +++ b/backend/app/views/spree/admin/stock_locations/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_locations), spree.admin_stock_locations_url %> / + <%= @stock_location.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @stock_location } %> + +<%= form_for [:admin, @stock_location] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_locations/index.html.erb b/backend/app/views/spree/admin/stock_locations/index.html.erb new file mode 100644 index 00000000000..7fc5a57eef9 --- /dev/null +++ b/backend/app/views/spree/admin/stock_locations/index.html.erb @@ -0,0 +1,41 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::StockLocation) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_stock_location), new_object_url, { class: "btn-success", icon: 'add', id: 'admin_new_stock_location' } %> +<% end if can? :create, Spree::StockLocation %> + +<% if @stock_locations.any? %> + + + + + + + + + + + <% @stock_locations.each do |stock_location| + @edit_url = edit_admin_stock_location_path(stock_location) + @delete_url = admin_stock_location_path(stock_location) + %> + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:status) %><%= Spree.t(:stock_movements) %>
    <%= display_name(stock_location) %><%= Spree.t(state(stock_location)) %><%= link_to Spree.t(:stock_movements), admin_stock_location_stock_movements_path(stock_location.id) %> + <%= link_to_edit(stock_location, no_text: true) if can? :create, stock_location %> + <%= link_to_delete(stock_location, no_text: true) if can? :create, stock_location %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StockLocation)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::StockLocation %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_locations/new.html.erb b/backend/app/views/spree/admin/stock_locations/new.html.erb new file mode 100644 index 00000000000..a5de1c7765d --- /dev/null +++ b/backend/app/views/spree/admin/stock_locations/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_locations), spree.admin_stock_locations_url %> / + <%= Spree.t(:new_stock_location) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @stock_location } %> + +<%= form_for [:admin, @stock_location] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_movements/_form.html.erb b/backend/app/views/spree/admin/stock_movements/_form.html.erb new file mode 100644 index 00000000000..b5439cce901 --- /dev/null +++ b/backend/app/views/spree/admin/stock_movements/_form.html.erb @@ -0,0 +1,12 @@ +
    + <%= f.field_container :quantity, class: ['form-group'] do %> + <%= f.label :quantity, Spree.t(:quantity) %> + <%= f.text_field :quantity, class: 'form-control' %> + <% end %> + <%= f.field_container :stock_item_id, class: ['form-group'] do %> + <%= f.label :stock_item_id, Spree.t(:stock_item_id) %> + <%= f.text_field 'stock_item_id', :'data-stock-location-id' => params[:stock_location_id] %> + <% end %> +
    + +<%= render partial: "spree/admin/variants/autocomplete", formats: :js %> diff --git a/backend/app/views/spree/admin/stock_movements/index.html.erb b/backend/app/views/spree/admin/stock_movements/index.html.erb new file mode 100644 index 00000000000..f5a820b0339 --- /dev/null +++ b/backend/app/views/spree/admin/stock_movements/index.html.erb @@ -0,0 +1,43 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_locations), spree.admin_stock_locations_path %> / + <%= Spree.t(:stock_movements_for_stock_location, stock_location_name: @stock_location.name) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_stock_movement), new_admin_stock_location_stock_movement_path(@stock_location), icon: 'add', class: 'btn-success', id: 'admin_new_stock_movement_link' %> +<% end %> + +<% if @stock_movements.any? %> + + + + + + + + + + + + + + <% @stock_movements.each do |stock_movement|%> + + + + + + <% end %> + +
    <%= Spree.t(:stock_item) %> + <%= Spree.t(:quantity) %><%= Spree.t(:action) %>
    + <%= display_variant(stock_movement) %> + <%= stock_movement.quantity %><%= pretty_originator(stock_movement) %>
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StockMovement)) %>, + <%= link_to Spree.t(:add_one), spree.new_admin_stock_location_stock_movement_path(@stock_location) %>! +
    +<% end %> + +<%= paginate @stock_movements %> diff --git a/backend/app/views/spree/admin/stock_movements/new.html.erb b/backend/app/views/spree/admin/stock_movements/new.html.erb new file mode 100644 index 00000000000..e186152a547 --- /dev/null +++ b/backend/app/views/spree/admin/stock_movements/new.html.erb @@ -0,0 +1,14 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_locations), spree.admin_stock_locations_url %> / + <%= link_to Spree.t(:stock_movements), spree.admin_stock_location_stock_movements_path(stock_location) %> / + <%= Spree.t(:new_stock_movement) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @stock_movement } %> + +<%= form_for [:admin, stock_location, @stock_movement] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links', locals: { collection_url: admin_stock_location_stock_movements_path(stock_location) } %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_transfers/_stock_movements.html.erb b/backend/app/views/spree/admin/stock_transfers/_stock_movements.html.erb new file mode 100644 index 00000000000..08ad4f68d02 --- /dev/null +++ b/backend/app/views/spree/admin/stock_transfers/_stock_movements.html.erb @@ -0,0 +1,22 @@ +
    + + + + + + + + + + + <% stock_movements.each do |movement| %> + + + + + + + <% end %> + +
    <%= Spree.t('variant') %><%= Spree.t('sku') %><%= Spree.t('quantity') %><%= Spree.t('count_on_hand') %>
    <%= movement.stock_item.variant.name %><%= movement.stock_item.variant.sku %><%= movement.quantity %><%= movement.stock_item.count_on_hand %>
    +
    diff --git a/backend/app/views/spree/admin/stock_transfers/index.html.erb b/backend/app/views/spree/admin/stock_transfers/index.html.erb new file mode 100644 index 00000000000..8cb4cb16966 --- /dev/null +++ b/backend/app/views/spree/admin/stock_transfers/index.html.erb @@ -0,0 +1,82 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::StockTransfer) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_stock_transfer), new_admin_stock_transfer_path, { icon: 'add', class: 'btn-success' } %> +<% end if can? :create, Spree::StockTransfer %> + +
    +
    + <%= Spree.t(:search) %> + <%= search_form_for @q, url: admin_stock_transfers_path do |f| %> + +
    +
    +
    + <%= f.label :reference_cont, Spree.t(:reference_contains) %> + <%= f.text_field :reference_cont, class: 'form-control' %> +
    +
    + +
    +
    + <%= f.label :source_location, Spree.t(:source) %> + <%= f.select :source_location_id_eq, + options_from_collection_for_select(@stock_locations, :id, :name, @q.source_location_id_eq), + { include_blank: true }, class: 'select2' %> +
    +
    + +
    +
    + <%= f.label :destination_location, Spree.t(:destination) %> + <%= f.select :destination_location_id_eq, + options_from_collection_for_select(@stock_locations, :id, :name, @q.destination_location_id_eq), + { include_blank: true }, class: 'select2' %> +
    +
    +
    + +
    +
    + <%= button Spree.t(:filter_results), 'search' %> +
    +
    + <% end %> +
    +
    + +<% if @stock_transfers.any? %> + + + + + + + + + + + + <% @stock_transfers.each do |stock_transfer| %> + + + + + + + + <% end %> + +
    <%= Spree.t(:created_at) %><%= Spree.t(:reference) %><%= Spree.t(:source) %><%= Spree.t(:destination) %>
    <%= link_to stock_transfer.created_at, admin_stock_transfer_path(stock_transfer) %><%= stock_transfer.reference %><%= stock_transfer.source_location.try(:name) %><%= stock_transfer.destination_location.try(:name) %> + <%= link_to_with_icon 'show', Spree.t(:show), admin_stock_transfer_path(stock_transfer), class: 'btn btn-default btn-sm', no_text: true, data: {action: 'view'} %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StockTransfer)) %>, + <%= link_to(Spree.t(:add_one), new_admin_stock_transfer_path) if can? :create, Spree::StockTransfer %>! +
    +<% end %> + +<%= paginate @stock_transfers %> diff --git a/backend/app/views/spree/admin/stock_transfers/new.html.erb b/backend/app/views/spree/admin/stock_transfers/new.html.erb new file mode 100644 index 00000000000..b9c84c2ef93 --- /dev/null +++ b/backend/app/views/spree/admin/stock_transfers/new.html.erb @@ -0,0 +1,107 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_transfers), spree.admin_stock_transfers_url %> / + <%= Spree.t(:new_stock_transfer) %> +<% end %> + + + +<%= form_tag admin_stock_transfers_path, method: :post do %> +
    +
    +

    + <%= Spree.t(:transfer_stock) %> +

    +
    + +
    +
    +
    + <%= label_tag 'reference', raw("#{Spree.t(:reference)} (#{Spree.t(:optional)})") %> + <%= text_field_tag :reference, '', class: 'form-control' %> +
    +
    +
    + +
    +
    +
    + <%= label_tag :transfer_source_location_id, Spree.t(:source) %> + <%= select_tag :transfer_source_location_id, {}, class: 'select2' %> +
    +
    + <%= label_tag :transfer_destination_location_id, Spree.t(:destination) %> + <%= select_tag :transfer_destination_location_id, {}, class: 'select2' %> +
    +
    +
    +
    + +
    +
    +

    + <%= Spree.t(:add_variant) %> +

    +
    + +
    +
    +
    +
    + <%= label_tag 'variant_id', Spree.t(:variant) %> + <%= hidden_field_tag 'transfer_variant', {}, {class: 'fullwidth-input'} %> +
    +
    + +
    +
    + <%= label_tag :transfer_variant_quantity, Spree.t(:quantity) %> +
    + <%= number_field_tag :transfer_variant_quantity, 1, class: 'form-control', min: 1 %> + + <%= button Spree.t(:add), 'plus', 'submit', class: "transfer_add_variant" %> + +
    +
    +
    +
    +
    +
    + +
    + <%= Spree.t(:no_resource_found, resource: I18n.t(:other, scope: 'activerecord.models.spree/variant')) %>. +
    + + + +
    + <%= button Spree.t(:transfer_stock), 'plus transfer_transfer' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stock_transfers/show.html.erb b/backend/app/views/spree/admin/stock_transfers/show.html.erb new file mode 100644 index 00000000000..00889e0896f --- /dev/null +++ b/backend/app/views/spree/admin/stock_transfers/show.html.erb @@ -0,0 +1,44 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stock_transfers), spree.admin_stock_transfers_url %> / + <%= @stock_transfer.number %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_stock_transfer), new_admin_stock_transfer_path, { icon: 'add', class: 'btn-success' } %> +<% end if can? :create, Spree::StockTransfer %> + +
    + <%= Spree.t(:stock_transfer_name) %> + + + + + + + <% if @stock_transfer.reference.present? %> + + + + + <% end %> +
    <%= Spree.t(:created_at) %><%= @stock_transfer.created_at %>
    <%= Spree.t(:reference) %><%= @stock_transfer.reference %>
    + + <% if @stock_transfer.source_movements.present? %> +
    + + <%= Spree.t(:source) %> / <%= @stock_transfer.source_location.name %> + + <%= render partial: 'stock_movements', object: @stock_transfer.source_movements %> +
    + <% end %> + + <% if @stock_transfer.destination_movements.present? %> +
    + + <%= Spree.t(:destination) %> / <%= @stock_transfer.destination_location.name %> + + <%= render partial: 'stock_movements', object: @stock_transfer.destination_movements %> +
    + <% end %> + +
    diff --git a/backend/app/views/spree/admin/store_credit_categories/_form.html.erb b/backend/app/views/spree/admin/store_credit_categories/_form.html.erb new file mode 100644 index 00000000000..3652dbc6a61 --- /dev/null +++ b/backend/app/views/spree/admin/store_credit_categories/_form.html.erb @@ -0,0 +1,9 @@ +
    + <%= f.field_container :name, class: ['form-group'], 'data-hook' => 'store_credit_category_name' do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + +
    +
    diff --git a/backend/app/views/spree/admin/store_credit_categories/edit.html.erb b/backend/app/views/spree/admin/store_credit_categories/edit.html.erb new file mode 100644 index 00000000000..b3b74df0f01 --- /dev/null +++ b/backend/app/views/spree/admin/store_credit_categories/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:store_credit_categories), spree.admin_store_credit_categories_url %> / + <%= @store_credit_category.name %> +<% end %> + +<%= render 'spree/admin/shared/error_messages', target: @store_credit_category %> + +<%= form_for [:admin, @store_credit_category] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/store_credit_categories/index.html.erb b/backend/app/views/spree/admin/store_credit_categories/index.html.erb new file mode 100644 index 00000000000..db7529f6c4a --- /dev/null +++ b/backend/app/views/spree/admin/store_credit_categories/index.html.erb @@ -0,0 +1,34 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::StoreCreditCategory) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_store_credit_category), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_store_credit_category_link' %> +<% end if can? :create, Spree::StoreCreditCategory %> + +<% if @store_credit_categories.any? %> + + + + + + + + + <% @store_credit_categories.each do |store_credit_category| %> + + + + + <% end %> + +
    <%= Spree.t(:name) %>
    <%= store_credit_category.name %> + <%= link_to_edit(store_credit_category, no_text: true) if can? :edit, store_credit_category %> + <%= link_to_delete(store_credit_category, no_text: true) if can? :destroy, store_credit_category %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StoreCreditCategory)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::StoreCreditCategory %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/store_credit_categories/new.html.erb b/backend/app/views/spree/admin/store_credit_categories/new.html.erb new file mode 100644 index 00000000000..a8840ef20ea --- /dev/null +++ b/backend/app/views/spree/admin/store_credit_categories/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:store_credit_categories), spree.admin_store_credit_categories_url %> / + <%= Spree.t(:new_store_credit_category) %> +<% end %> + +<%= render 'spree/admin/shared/error_messages', target: @store_credit_category %> + +<%= form_for [:admin, @store_credit_category] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/store_credits/_form.html.erb b/backend/app/views/spree/admin/store_credits/_form.html.erb new file mode 100644 index 00000000000..4e9e7a69feb --- /dev/null +++ b/backend/app/views/spree/admin/store_credits/_form.html.erb @@ -0,0 +1,27 @@ +
    + <%= f.field_container :amount, class: %w[form-group] do %> + <%= f.label :amount, raw(Spree.t(:amount) + content_tag(:span, ' *', class: 'required')) %> + <%= f.text_field :amount, class: 'form-control' %> + <%= f.error_message_on :amount %> + <% end %> + <%= f.field_container :currency, class: %w[form-group] do %> + <%= f.label :currency, raw(Spree.t(:currency) + content_tag(:span, ' *', class: 'required')) %> + <%= f.select :currency, currency_options(f.object.currency) %> + <%= f.error_message_on :currency %> + <% end %> + <%= f.field_container :category, class: %w[form-group] do %> + <%= f.label :category, raw(Spree.t(:category) + content_tag(:span, ' *', class: 'required')) %> + <%= f.select :category_id, options_from_collection_for_select(@credit_categories, :id, :name, f.object.category.try(:id)), + { include_blank: true }, { class: 'select2 fullwidth', placeholder: Spree.t(:select_a_store_credit_reason) } %> + <%= f.error_message_on :category %> + <% end %> + <%= f.field_container :memo, class: %w[form-group] do %> + <%= f.label :memo, Spree.t(:memo) %> + <%= f.text_area :memo, class: 'form-control' %> + <%= f.error_message_on :memo %> + <% end %> +
    + + diff --git a/backend/app/views/spree/admin/store_credits/edit.html.erb b/backend/app/views/spree/admin/store_credits/edit.html.erb new file mode 100644 index 00000000000..5f5677fc594 --- /dev/null +++ b/backend/app/views/spree/admin/store_credits/edit.html.erb @@ -0,0 +1,14 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :store_credits } %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= link_to Spree.t(:'admin.user.store_credits'), spree.admin_user_store_credits_path(@user) %> / + <%= Spree.t(:editing_resource, resource: Spree::StoreCredit.model_name.human) %> +<% end %> + +<%= form_for @store_credit, url: spree.admin_user_store_credit_path(@user, @store_credit) do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links', locals: { collection_url: spree.admin_user_store_credits_path(@user) } %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/store_credits/index.html.erb b/backend/app/views/spree/admin/store_credits/index.html.erb new file mode 100644 index 00000000000..bf5ce92f424 --- /dev/null +++ b/backend/app/views/spree/admin/store_credits/index.html.erb @@ -0,0 +1,46 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :store_credits } %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= Spree.t(:"admin.user.store_credits") %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:add_store_credit), spree.new_admin_user_store_credit_path(@user), class: "btn-success", icon: 'add' if can?(:create, Spree::StoreCredit) %> +<% end %> + +<% if @store_credits.any? %> + + + + + + + + + + + <% @store_credits.each do |store_credit| %> + + + + + + + + + <% end %> + +
    <%= Spree.t(:credited) %><%= Spree.t(:used) %><%= Spree.t(:category) %><%= Spree.t(:created_by) %><%= Spree.t(:issued_on) %>
    <%= store_credit.display_amount.to_html %><%= store_credit.display_amount_used.to_html %><%= store_credit.category_name %><%= store_credit.created_by_email %><%= l store_credit.created_at.to_date %> + <% if store_credit.amount_used.zero? %> + <%= link_to_edit_url spree.edit_admin_user_store_credit_path(@user, store_credit), no_text: true if can?(:edit, store_credit) %> + <%= link_to_delete store_credit, no_text: true, url: spree.admin_user_store_credit_path(@user, store_credit) if can?(:destroy, store_credit) %> + <% end %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StoreCredit)) %> +
    +<% end %> + +<%= render 'spree/admin/users/lifetime_stats' %> diff --git a/backend/app/views/spree/admin/store_credits/new.html.erb b/backend/app/views/spree/admin/store_credits/new.html.erb new file mode 100644 index 00000000000..8660300cb66 --- /dev/null +++ b/backend/app/views/spree/admin/store_credits/new.html.erb @@ -0,0 +1,14 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :store_credits } %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= link_to Spree.t(:'admin.user.store_credits'), spree.admin_user_store_credits_path(@user) %> / + <%= Spree.t(:add_store_credit) %> +<% end %> + +<%= form_for @store_credit, url: spree.admin_user_store_credits_path(@user, @store_credit) do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links', locals: { collection_url: spree.admin_user_store_credits_path(@user) } %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stores/_form.html.erb b/backend/app/views/spree/admin/stores/_form.html.erb new file mode 100644 index 00000000000..8e02887f80a --- /dev/null +++ b/backend/app/views/spree/admin/stores/_form.html.erb @@ -0,0 +1,43 @@ +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control', required: true %> + <%= f.error_message_on :name %> + <% end %> + <%= f.field_container :url, class: ['form-group'] do %> + <%= f.label :url, Spree.t(:url) %> + <%= f.text_field :url, class: 'form-control', required: true %> + <%= f.error_message_on :url %> + <% end %> + <%= f.field_container :meta_description, class: ['form-group'] do %> + <%= f.label :meta_description, Spree.t(:meta_description) %> + <%= f.text_field :meta_description, class: 'form-control' %> + <%= f.error_message_on :meta_description %> + <% end %> + <%= f.field_container :meta_keywords, class: ['form-group'] do %> + <%= f.label :meta_keywords, Spree.t(:meta_keywords) %> + <%= f.text_field :meta_keywords, class: 'form-control' %> + <%= f.error_message_on :meta_keywords %> + <% end %> + <%= f.field_container :seo_title, class: ['form-group'] do %> + <%= f.label :seo_title, Spree.t(:seo_title) %> + <%= f.text_field :seo_title, class: 'form-control' %> + <%= f.error_message_on :seo_title %> + <% end %> + <%= f.field_container :mail_from_address, class: ['form-group'] do %> + <%= f.label :mail_from_address, Spree.t(:mail_from_address) %> + <%= f.text_field :mail_from_address, class: 'form-control', required: true %> + <%= f.error_message_on :mail_from_address %> + <% end %> + <%# TODO: uncomment this and add support for default_currency in store %> + <%# <%= f.field_container :default_currency, class: ['form-group'] do %1> %> + <%# <%= f.label :default_currency, Spree.t(:default_currency) %1> %> + <%# <%= f.text_field :default_currency, class: 'form-control' %1> %> + <%# <%= f.error_message_on :default_currency %1> %> + <%# <% end %1> %> + <%= f.field_container :code, class: ['form-group'] do %> + <%= f.label :code, Spree.t(:code) %> + <%= f.text_field :code, class: 'form-control', required: true %> + <%= f.error_message_on :code %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/stores/edit.html.erb b/backend/app/views/spree/admin/stores/edit.html.erb new file mode 100644 index 00000000000..1d7dce17aa6 --- /dev/null +++ b/backend/app/views/spree/admin/stores/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stores), spree.admin_stores_url %> / + <%= @store.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @store } %> + +<%= form_for [:admin, @store] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links', locals: { collection_url: spree.admin_stores_path(@store) } %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stores/index.html.erb b/backend/app/views/spree/admin/stores/index.html.erb new file mode 100644 index 00000000000..74cd4ab4e55 --- /dev/null +++ b/backend/app/views/spree/admin/stores/index.html.erb @@ -0,0 +1,38 @@ +<% content_for :page_title do %> + <%= Spree.t(:"admin.user.stores") %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:add_store), spree.new_admin_store_path(@user), class: "btn-success", icon: 'add' if can?(:create, Spree::Store) %> +<% end %> + +<% if @stores.any? %> + + + + + + + + <% @stores.each do |store| %> + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:url) %>
    <%= store.name %><%= store.url %> + <% if store.default %> + <%= button_link_to(Spree.t(:store_default), '#', class: 'btn-success btn-sm') %> + <% else %> + <%= button_link_to(Spree.t(:store_set_default_button), spree.set_default_admin_store_path(store), method: :post, class: 'btn-default btn-sm') if can?(:edit, store) %> + <% end %> + <%= link_to_edit_url spree.edit_admin_store_path(store), no_text: true if can?(:edit, store) %> + <%= link_to_delete store, no_text: true, url: spree.admin_store_path(store) if can?(:destroy, store) %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::StoreCredit)) %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/stores/new.html.erb b/backend/app/views/spree/admin/stores/new.html.erb new file mode 100644 index 00000000000..5bae904535d --- /dev/null +++ b/backend/app/views/spree/admin/stores/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:stores), spree.admin_stores_path(@store) %> / + <%= Spree.t(:add_store) %> +<% end %> + +<%= form_for @store, url: spree.admin_stores_path(@store) do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links', locals: { collection_url: spree.admin_stores_path(@store) } %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/tax_categories/_form.html.erb b/backend/app/views/spree/admin/tax_categories/_form.html.erb new file mode 100644 index 00000000000..81b6e71247c --- /dev/null +++ b/backend/app/views/spree/admin/tax_categories/_form.html.erb @@ -0,0 +1,24 @@ +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name %> + <% end %> + + <%= f.field_container :tax_code, class: ['form-group'] do %> + <%= f.label :tax_code, Spree.t(:tax_code) %> + <%= f.text_field :tax_code, class: 'form-control' %> + <% end %> + + <%= f.field_container :description, class: ['form-group'] do %> + <%= f.label :description, Spree.t(:description) %>
    + <%= f.text_field :description, class: 'form-control' %> + <% end %> + + <%= f.field_container :is_default, class: ['checkbox'] do %> + <%= f.label :is_default do %> + <%= f.check_box :is_default %> + <%= Spree.t(:default) %> + <% end %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/tax_categories/edit.html.erb b/backend/app/views/spree/admin/tax_categories/edit.html.erb new file mode 100644 index 00000000000..bad4abdc13c --- /dev/null +++ b/backend/app/views/spree/admin/tax_categories/edit.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:tax_categories), spree.admin_tax_categories_url %> / + <%= @tax_category.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @tax_category } %> + +<%= form_for [:admin, @tax_category] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/tax_categories/index.html.erb b/backend/app/views/spree/admin/tax_categories/index.html.erb new file mode 100644 index 00000000000..8b929e0c40b --- /dev/null +++ b/backend/app/views/spree/admin/tax_categories/index.html.erb @@ -0,0 +1,42 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::TaxCategory) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_tax_category), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_tax_categories_link' %> +<% end if can? :create, Spree::TaxCategory %> + +<% if @tax_categories.any? %> + + + + + + + + + + + + <% @tax_categories.each do |tax_category| + @edit_url = edit_admin_tax_category_path(tax_category) + @delete_url = admin_tax_category_path(tax_category) + %> + + + + + + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:tax_code) %><%= Spree.t(:description) %><%= Spree.t(:default) %>
    <%= tax_category.name %><%= tax_category.tax_code %><%= tax_category.description %><%= tax_category.is_default? ? Spree.t(:say_yes) : Spree.t(:say_no) %> + <%= link_to_edit(tax_category, no_text: true) if can? :edit, tax_category %> + <%= link_to_delete(tax_category, no_text: true) if can? :delete, tax_category %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::TaxCategory)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::TaxCategory %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/tax_categories/new.html.erb b/backend/app/views/spree/admin/tax_categories/new.html.erb new file mode 100644 index 00000000000..5210e65e881 --- /dev/null +++ b/backend/app/views/spree/admin/tax_categories/new.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:tax_categories), spree.admin_tax_categories_url %> / + <%= Spree.t(:new_tax_category) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @tax_category } %> + +<%= form_for [:admin, @tax_category] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/vendor/plugins/rspec_on_rails/spec_resources/views/controller_spec/action_setting_flash_after_session_reset.rhtml b/backend/app/views/spree/admin/tax_categories/show.html.erb similarity index 100% rename from vendor/plugins/rspec_on_rails/spec_resources/views/controller_spec/action_setting_flash_after_session_reset.rhtml rename to backend/app/views/spree/admin/tax_categories/show.html.erb diff --git a/backend/app/views/spree/admin/tax_rates/_form.html.erb b/backend/app/views/spree/admin/tax_rates/_form.html.erb new file mode 100644 index 00000000000..b1cd699c0e1 --- /dev/null +++ b/backend/app/views/spree/admin/tax_rates/_form.html.erb @@ -0,0 +1,54 @@ +
    +
    +
    +

    + <%= Spree.t(:general_settings) %> +

    +
    + +
    +
    +
    + <%= f.label :name, Spree.t(:name) %> + <%= f.text_field :name, class: 'form-control' %> +
    +
    + <%= f.field_container :amount, class: ["form-group"] do %> + <%= f.label :amount, Spree.t(:rate) %> + <%= f.text_field :amount, class: 'form-control' %> + <%= f.error_message_on :amount %> + <% end %> +

    + <%= Spree.t(:tax_rate_amount_explanation) %> +

    +
    +
    + <%= f.label :included_in_price do %> + <%= f.check_box :included_in_price %> + <%= Spree.t(:included_in_price) %> + <% end %> +
    +
    + +
    +
    + <%= f.label :zone, Spree.t(:zone) %> + <%= f.collection_select(:zone_id, @available_zones, :id, :name, {}, {class: 'select2'}) %> +
    +
    + <%= f.label :tax_category_id, Spree.t(:tax_category) %> + <%= f.collection_select(:tax_category_id, @available_categories,:id, :name, {}, {class: 'select2'}) %> +
    +
    + <%= f.label :show_rate_in_label do %> + <%= f.check_box :show_rate_in_label %> + <%= Spree.t(:show_rate_in_label) %> + <% end %> +
    +
    +
    + +
    +
    + +<%= render 'spree/admin/shared/calculator_fields', f: f %> diff --git a/backend/app/views/spree/admin/tax_rates/edit.html.erb b/backend/app/views/spree/admin/tax_rates/edit.html.erb new file mode 100644 index 00000000000..5fd76792d53 --- /dev/null +++ b/backend/app/views/spree/admin/tax_rates/edit.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:tax_rates), spree.admin_tax_rates_url %> / + <%= @tax_rate.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @tax_rate } %> + +<%= form_for [:admin, @tax_rate] do |f| %> + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/admin/tax_rates/index.html.erb b/backend/app/views/spree/admin/tax_rates/index.html.erb new file mode 100644 index 00000000000..836c70f250c --- /dev/null +++ b/backend/app/views/spree/admin/tax_rates/index.html.erb @@ -0,0 +1,46 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::TaxRate) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_tax_rate), new_object_url, class: "btn-success", icon: 'add' %> +<% end if can? :create, Spree::TaxRate %> + +<% if @tax_rates.any? %> + + + + + + + + + + + + + + + <% @tax_rates.each do |tax_rate|%> + + + + + + + + + + + <% end %> + +
    <%= Spree.t(:zone) %><%= Spree.t(:name) %><%= Spree.t(:tax_category) %><%= Spree.t(:amount) %><%= Spree.t(:included_in_price) %><%= Spree.t(:show_rate_in_label) %><%= Spree.t(:calculator) %>
    <%=tax_rate.zone.try(:name) || Spree.t(:not_available) %><%=tax_rate.name %><%=tax_rate.tax_category.try(:name) || Spree.t(:not_available) %><%=tax_rate.amount %><%=tax_rate.included_in_price? ? Spree.t(:say_yes) : Spree.t(:say_no) %><%=tax_rate.show_rate_in_label? ? Spree.t(:say_yes) : Spree.t(:say_no) %><%=tax_rate.calculator.to_s %> + <%= link_to_edit(tax_rate, no_text: true) if can? :edit, tax_rate %> + <%= link_to_delete(tax_rate, no_text: true) if can? :delete, tax_rate %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::TaxRate)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::TaxRate %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/tax_rates/new.html.erb b/backend/app/views/spree/admin/tax_rates/new.html.erb new file mode 100644 index 00000000000..3900f3a99a4 --- /dev/null +++ b/backend/app/views/spree/admin/tax_rates/new.html.erb @@ -0,0 +1,14 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:tax_rates), spree.admin_tax_rates_url %> / + <%= Spree.t(:new_tax_rate) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @tax_rate } %> + +<%= form_for [:admin, @tax_rate] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/taxonomies/_form.html.erb b/backend/app/views/spree/admin/taxonomies/_form.html.erb new file mode 100644 index 00000000000..2add6481222 --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/_form.html.erb @@ -0,0 +1,7 @@ +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= f.text_field :name, class: 'form-control' %> + <%= f.error_message_on :name, class: 'error-message' %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/taxonomies/_js_head.html.erb b/backend/app/views/spree/admin/taxonomies/_js_head.html.erb new file mode 100644 index 00000000000..aa10791a2f1 --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/_js_head.html.erb @@ -0,0 +1,8 @@ +<% content_for :head do %> + <%= javascript_tag "var taxonomy_id = #{@taxonomy.id}; + $(document).ready(function(){ + setup_taxonomy_tree(taxonomy_id); + }); + " + %> +<% end %> diff --git a/backend/app/views/spree/admin/taxonomies/_list.html.erb b/backend/app/views/spree/admin/taxonomies/_list.html.erb new file mode 100644 index 00000000000..4fa8b26ce42 --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/_list.html.erb @@ -0,0 +1,23 @@ + + + + + + + + + + <% @taxonomies.each do |taxonomy| %> + + + + + + <% end %> + +
    <%= Spree.t(:name) %>
    + + <%= taxonomy.name %> + <%= link_to_edit taxonomy.id, no_text: true if can?(:edit, taxonomy) %> + <%= link_to_delete taxonomy, no_text: true if can?(:delete, taxonomy) %> +
    diff --git a/backend/app/views/spree/admin/taxonomies/edit.html.erb b/backend/app/views/spree/admin/taxonomies/edit.html.erb new file mode 100755 index 00000000000..c3bbcd68cde --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/edit.html.erb @@ -0,0 +1,37 @@ +<%= render partial: 'js_head' %> + +<% content_for :page_title do %> + <%= link_to Spree.t(:taxonomies), spree.admin_taxonomies_url %> / + <%= @taxonomy.name %> +<% end %> + + + +<%= form_for [:admin, @taxonomy] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> +
    + <%= label_tag nil, Spree.t(:tree) %> + +
    +
    + <%= Spree.t(:loading_tree) %> +
    +
    +
    + +
    + <%= Spree.t(:taxonomy_tree_instruction) %> +
    + +
    + <%= button Spree.t('actions.update'), 'ok', 'submit', { class: 'btn-success' } %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), admin_taxonomies_path, icon: 'delete' %> +
    +
    +<% end %> diff --git a/backend/app/views/spree/admin/taxonomies/index.html.erb b/backend/app/views/spree/admin/taxonomies/index.html.erb new file mode 100644 index 00000000000..8b428997dc0 --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/index.html.erb @@ -0,0 +1,18 @@ +<% content_for :page_title do %> + <%= plural_resource_name(Spree::Taxonomy) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_taxonomy), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_taxonomy_link' %> +<% end if can?(:create, Spree::Taxonomy) %> + +<% if @taxonomies.any? %> +
    + <%= render 'list' %> +
    +<% else %> +
    + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Taxonomy)) %>, + <%= link_to Spree.t(:add_one), new_object_url if can?(:create, Spree::Taxonomy) %>! +
    +<% end %> diff --git a/backend/app/views/spree/admin/taxonomies/new.html.erb b/backend/app/views/spree/admin/taxonomies/new.html.erb new file mode 100644 index 00000000000..08a2a2b5c10 --- /dev/null +++ b/backend/app/views/spree/admin/taxonomies/new.html.erb @@ -0,0 +1,12 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:taxonomies), spree.admin_taxonomies_url %> / + <%= Spree.t(:new_taxonomy) %> +<% end %> +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @taxonomy } %> + +<%= form_for [:admin, @taxonomy] do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/taxons/_form.html.erb b/backend/app/views/spree/admin/taxons/_form.html.erb new file mode 100644 index 00000000000..253b6ca60c5 --- /dev/null +++ b/backend/app/views/spree/admin/taxons/_form.html.erb @@ -0,0 +1,47 @@ +
    +
    +
    + <%= f.field_container :name, class: ['form-group'] do %> + <%= f.label :name, Spree.t(:name) %> * + <%= text_field :taxon, :name, class: 'form-control' %> + <%= f.error_message_on :name, class: 'error-message' %> + <% end %> + + <%= f.field_container :permalink, class: ['form-group'] do %> + <%= label_tag :permalink_part, Spree.t(:permalink) %> * + <%= text_field_tag :permalink_part, @permalink_part, class: 'form-control', required: true %> + + <% end %> + + <%= f.field_container :icon, class: ['form-group'] do %> + <%= f.label :icon, Spree.t(:icon) %> + <%= image_tag main_app.url_for(@taxon.icon.try(:attachment)) if @taxon.icon %> + <%= f.file_field :icon %> + <% end %> +
    + +
    + <%= f.field_container :description, class: ['form-group'] do %> + <%= f.label :description, Spree.t(:description) %> + <%= f.text_area :description, class: 'form-control', rows: 6 %> + <% end %> +
    +
    + + <%= f.field_container :meta_title, class: ['form-group'] do %> + <%= f.label :meta_title, Spree.t(:meta_title) %> + <%= f.text_field :meta_title, class: 'form-control', rows: 6 %> + <% end %> + + <%= f.field_container :meta_description, class: ['form-group'] do %> + <%= f.label :meta_description, Spree.t(:meta_description) %> + <%= f.text_field :meta_description, class: 'form-control', rows: 6 %> + <% end %> + + <%= f.field_container :meta_keywords, class: ['form-group'] do %> + <%= f.label :meta_keywords, Spree.t(:meta_keywords) %> + <%= f.text_field :meta_keywords, class: 'form-control', rows: 6 %> + <% end %> +
    diff --git a/backend/app/views/spree/admin/taxons/_taxon_table.html.erb b/backend/app/views/spree/admin/taxons/_taxon_table.html.erb new file mode 100644 index 00000000000..b14263750f2 --- /dev/null +++ b/backend/app/views/spree/admin/taxons/_taxon_table.html.erb @@ -0,0 +1,23 @@ + + + + + + + + + + <% taxons.each do |taxon| %> + + + + + + <% end %> + <% if taxons.empty? %> + + <% end %> + +
    <%= Spree.t(:name) %><%= Spree.t(:path) %>
    <%= taxon.name %><%= taxon_path taxon %> + <%= link_to_delete taxon, url: remove_admin_product_taxon_url(@product, taxon), name: icon('delete') + ' ' + Spree.t(:remove) %> +
    <%= Spree.t(:none) %>.
    diff --git a/backend/app/views/spree/admin/taxons/edit.html.erb b/backend/app/views/spree/admin/taxons/edit.html.erb new file mode 100644 index 00000000000..8dd075f5675 --- /dev/null +++ b/backend/app/views/spree/admin/taxons/edit.html.erb @@ -0,0 +1,20 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:taxonomies), spree.admin_taxonomies_url %> / + <%= link_to @taxonomy.name, spree.edit_admin_taxonomy_url(@taxonomy) %> / + <%= @taxon.name %> +<% end %> + +<%# Because otherwise the form would attempt to use to_param of @taxon %> +<% form_url = admin_taxonomy_taxon_path(@taxonomy.id, @taxon.id) %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @taxon } %> + +<%= form_for [:admin, @taxonomy, @taxon], method: :put, url: form_url, html: { multipart: true } do |f| %> + <%= render 'form', f: f %> + +
    + <%= button Spree.t('actions.update'), 'save' %> + <%= Spree.t(:or) %> + <%= button_link_to Spree.t('actions.cancel'), edit_admin_taxonomy_url(@taxonomy), icon: "remove" %> +
    +<% end %> diff --git a/backend/app/views/spree/admin/taxons/index.html.erb b/backend/app/views/spree/admin/taxons/index.html.erb new file mode 100644 index 00000000000..30027bff16d --- /dev/null +++ b/backend/app/views/spree/admin/taxons/index.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title do %> + <%= Spree.t(:taxons) %> +<% end %> + +
    +
    + <%= text_field_tag :taxon_id %> +
    + +
      + + <%= render partial: "spree/admin/products/autocomplete", formats: :js %> +
      diff --git a/backend/app/views/spree/admin/users/_addresses_form.html.erb b/backend/app/views/spree/admin/users/_addresses_form.html.erb new file mode 100644 index 00000000000..28e33af91d5 --- /dev/null +++ b/backend/app/views/spree/admin/users/_addresses_form.html.erb @@ -0,0 +1,42 @@ +
      +
      +
      +
      +

      + <%= Spree.t(:billing_address) %> +

      +
      + +
      + <%= f.fields_for :bill_address, (@user.bill_address || Spree::Address.default(@user, "bill")) do |ba_form| %> + <% ba_form.object ||= Spree::Address.new(country: Spree::Country.new) %> + <%= render partial: 'spree/admin/shared/address_form', locals: { f: ba_form, type: "billing" } %> + <% end %> +
      +
      +
      + +
      +
      +
      +

      + <%= Spree.t(:shipping_address) %> +

      +
      + +
      +

      + <%= label_tag :use_billing, id: 'use_billing' do %> + <% checked = @user.bill_address.nil? ? false : @user.bill_address == @user.ship_address %> + <%= check_box_tag 'user[use_billing]', '1', checked %> + <%= Spree.t(:use_billing_address) %> + <% end %> +

      + <%= f.fields_for :ship_address, (@user.ship_address || Spree::Address.default(@user, "ship")) do |sa_form| %> + <% sa_form.object ||= Spree::Address.new(country: Spree::Country.new) %> + <%= render partial: 'spree/admin/shared/address_form', locals: { f: sa_form, type: "shipping" } %> + <% end %> +
      +
      +
      +
      diff --git a/backend/app/views/spree/admin/users/_form.html.erb b/backend/app/views/spree/admin/users/_form.html.erb new file mode 100644 index 00000000000..bb656cee258 --- /dev/null +++ b/backend/app/views/spree/admin/users/_form.html.erb @@ -0,0 +1,36 @@ +
      +
      + <%= f.field_container :email, class: ['form-group'] do %> + <%= f.label :email, Spree.t(:email) %> + <%= f.email_field :email, class: 'form-control' %> + <%= f.error_message_on :email %> + <% end %> + +
      + <%= Spree.t(:roles) %> + <%= f.collection_check_boxes :spree_role_ids, Spree::Role.all, :id, :name do |role_form| %> +
      + <%= role_form.label for: "user_spree_role_#{role_form.object.name}" do %> + <%= role_form.check_box id: "user_spree_role_#{role_form.object.name}" %> + <%= role_form.object.name %> + <% end %> +
      + <% end %> +
      + +
      + +
      + <%= f.field_container :password, class: ['form-group'] do %> + <%= f.label :password, Spree.t(:password) %> + <%= f.password_field :password, class: 'form-control' %> + <%= f.error_message_on :password %> + <% end %> + + <%= f.field_container :password_confirmation, class: ['form-group'] do %> + <%= f.label :password_confirmation, Spree.t(:confirm_password) %> + <%= f.password_field :password_confirmation, class: 'form-control' %> + <%= f.error_message_on :password_confirmation %> + <% end %> +
      +
      diff --git a/backend/app/views/spree/admin/users/_lifetime_stats.html.erb b/backend/app/views/spree/admin/users/_lifetime_stats.html.erb new file mode 100644 index 00000000000..782dc3332ee --- /dev/null +++ b/backend/app/views/spree/admin/users/_lifetime_stats.html.erb @@ -0,0 +1,30 @@ +
      +
      +

      + <%= Spree.t(:lifetime_stats) %> +

      +
      + + + + + + + + + + + + + + + + + + + + + + +
      <%= Spree.t(:total_sales) %>:<%= @user.display_lifetime_value.to_html %>
      <%= Spree.t(:num_orders) %>:<%= @user.order_count %>
      <%= Spree.t(:average_order_value) %>:<%= @user.display_average_order_value.to_html %>
      <%= Spree.t('admin.user.store_credits') %>:<%= Spree::Money.new(@user.total_available_store_credit) %>
      <%= Spree.t(:member_since) %>:<%= pretty_time(@user.created_at) %>
      +
      diff --git a/backend/app/views/spree/admin/users/_sidebar.html.erb b/backend/app/views/spree/admin/users/_sidebar.html.erb new file mode 100644 index 00000000000..27e3bcbf424 --- /dev/null +++ b/backend/app/views/spree/admin/users/_sidebar.html.erb @@ -0,0 +1,23 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:users), spree.admin_users_path %> / +<% end %> + +<% content_for :sidebar do %> + +<% end %> diff --git a/backend/app/views/spree/admin/users/_user_page_actions.html.erb b/backend/app/views/spree/admin/users/_user_page_actions.html.erb new file mode 100644 index 00000000000..0875852af1f --- /dev/null +++ b/backend/app/views/spree/admin/users/_user_page_actions.html.erb @@ -0,0 +1,3 @@ +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:create_new_order), spree.new_admin_order_path(user_id: @user.id), class: "btn-success", icon: 'add' if can?(:create, Spree::Order) %> +<% end %> diff --git a/backend/app/views/spree/admin/users/addresses.html.erb b/backend/app/views/spree/admin/users/addresses.html.erb new file mode 100644 index 00000000000..e09b90be082 --- /dev/null +++ b/backend/app/views/spree/admin/users/addresses.html.erb @@ -0,0 +1,26 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :address } %> +<%= render partial: 'spree/admin/users/user_page_actions' %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= Spree.t(:addresses) %> +<% end %> + +
      +
      + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @user } %> +
      + +
      + <%= form_for [:admin, @user], as: :user, url: addresses_admin_user_url(@user), method: :put do |f| %> + + <%= render partial: 'addresses_form', locals: { f: f } %> + +
      + <%= render partial: 'spree/admin/shared/edit_resource_links', locals: { collection_url: spree.admin_users_url } %> +
      + <% end %> +
      +
      + +<%= render 'spree/admin/users/lifetime_stats' %> diff --git a/backend/app/views/spree/admin/users/edit.html.erb b/backend/app/views/spree/admin/users/edit.html.erb new file mode 100644 index 00000000000..725e660fbaf --- /dev/null +++ b/backend/app/views/spree/admin/users/edit.html.erb @@ -0,0 +1,72 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :account } %> +<%= render partial: 'spree/admin/users/user_page_actions' %> + +<% content_for :page_title do %> + <%= @user.email %> +<% end %> + +
      +
      +

      + <%= Spree.t(:general_settings) %> +

      +
      + +
      +
      + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @user } %> +
      + +
      + <%= form_for [:admin, @user], as: :user, url: spree.admin_user_url(@user), method: :put do |f| %> + <%= render partial: 'form', locals: { f: f } %> + +
      + <%= render partial: 'spree/admin/shared/edit_resource_links', locals: { collection_url: spree.admin_users_url } %> +
      + <% end %> +
      +
      +
      + +
      +
      +

      + <%= Spree.t('access', scope: 'api') %> +

      +
      + +
      + <% if @user.spree_api_key.present? %> +
      +
      + + <%= @user.spree_api_key %> +
      +
      +
      + <%= form_tag spree.clear_api_key_admin_user_path(@user), method: :put do %> + <%= button Spree.t('clear_key', scope: 'api'), 'delete', 'submit', class: "btn-danger" %> + <% end %> + + <%= Spree.t(:or)%> + + <%= form_tag spree.generate_api_key_admin_user_path(@user), method: :put do %> + <%= button Spree.t('regenerate_key', scope: 'api'), 'save' %> + <% end %> +
      + + <% else %> + +
      <%= Spree.t('no_key', scope: 'api') %>
      + +
      + <%= form_tag spree.generate_api_key_admin_user_path(@user), method: :put do %> + <%= button Spree.t('generate_key', scope: 'api'), 'key' %> + <% end %> +
      + <% end %> +
      +
      + +<%= render 'spree/admin/users/lifetime_stats' %> diff --git a/backend/app/views/spree/admin/users/index.html.erb b/backend/app/views/spree/admin/users/index.html.erb new file mode 100644 index 00000000000..694c18a63f4 --- /dev/null +++ b/backend/app/views/spree/admin/users/index.html.erb @@ -0,0 +1,74 @@ +<% content_for :page_title do %> + <%= Spree.t(:users) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_user), new_admin_user_url, class: "btn-success", icon: 'add', id: 'admin_new_user_link' %> +<% end if can? :create, Spree::user_class %> + +<% content_for :table_filter_title do %> + <%= Spree.t(:search) %> +<% end %> + +<% content_for :table_filter do %> +
      + <%= search_form_for [:admin, @search], url: spree.admin_users_url do |f| %> +
      + <%= f.label :email_cont, Spree.t(:email) %> + <%= f.text_field :email_cont, class: "form-control js-quick-search-target js-filterable" %> +
      +
      +
      +
      + <%= f.label :bill_address_firstname_cont, Spree.t(:first_name) %> + <%= f.text_field :bill_address_firstname_cont, class: 'form-control js-filterable' %> +
      +
      +
      +
      + <%= f.label :bill_address_lastname_cont, Spree.t(:last_name) %> + <%= f.text_field :bill_address_lastname_cont, class: 'form-control js-filterable' %> +
      +
      +
      +
      + <%= f.label :bill_address_company_cont, Spree.t(:company) %> + <%= f.text_field :bill_address_company_cont, class: 'form-control js-filterable' %> +
      +
      + <%= button Spree.t(:search), 'search' %> +
      + <% end %> +
      +<% end %> + +<% if @users.any? %> + + + + + + + + + <% @users.each do |user|%> + + + + + <% end %> + +
      + <%= sort_link @search,:email, Spree.t(:user), {}, {title: 'users_email_title'} %> +
      <%=link_to user.email, edit_admin_user_url(user) %> + <%= link_to_edit user, no_text: true if can?(:edit, user) %> + <%= link_to_delete user, no_text: true if can?(:delete, user) %> +
      +<% else %> +
      + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree.user_class)) %>, + <%= link_to Spree.t(:add_one), new_object_url %>! +
      +<% end %> + +<%= paginate @users %> diff --git a/backend/app/views/spree/admin/users/items.html.erb b/backend/app/views/spree/admin/users/items.html.erb new file mode 100644 index 00000000000..3ca8b14682b --- /dev/null +++ b/backend/app/views/spree/admin/users/items.html.erb @@ -0,0 +1,66 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :items } %> +<%= render partial: 'spree/admin/users/user_page_actions' %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= Spree.t(:"admin.user.items_purchased") %> +<% end %> + +
      + <%= paginate @orders %> + + <% if @orders.any? %> + <%# TODO add search interface %> + + + + + + + + + + + + <% @orders.each do |order| %> + <% order.line_items.each do |item| %> + + + + + + + + + + + <% end %> + <% end %> +
      <%= sort_link @search, :completed_at, I18n.t(:completed_at, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_completed_at_title'} %><%= Spree.t(:description) %><%= I18n.t(:price, scope: 'activerecord.attributes.spree/line_item') %><%= I18n.t(:quantity, scope: 'activerecord.attributes.spree/line_item') %><%= Spree.t(:total) %><%= sort_link @search, :state, I18n.t(:state, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_state_title'} %><%= sort_link @search, :number, Spree.t(:order_num, scope: 'admin.user'), {}, {title: 'orders_number_title'} %>
      <%= order_time(order.completed_at) if order.completed_at %> + <%= mini_image(item.variant) %> + + <%= item.name %> +
      + <%= "(#{item.options_text})" if item.options_text.present? %> + <% if item.sku.present? %> + <%= Spree.t(:sku) %>: <%= item.variant.sku %> + <% end %> +
      <%= item.single_money.to_html %><%= item.quantity %><%= item.money.to_html %> +
      <%= Spree.t("order_state.#{order.state.downcase}") %>
      + <% if order.payment_state %> +
      <%= link_to Spree.t("payment_states.#{order.payment_state}"), admin_order_payments_path(order) %>
      + <% end %> + <% if Spree::Order.checkout_step_names.include?(:delivery) && order.shipment_state %> +
      <%= Spree.t("shipment_states.#{order.shipment_state}") %>
      + <% end %> +
      <%= link_to order.number, edit_admin_order_url(order) %>
      + <% else %> +
      + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Order)) %>, + <%= link_to Spree.t(:add_one), spree.new_admin_order_path %>! +
      + <% end %> + <%= paginate @orders %> +
      + +<%= render 'spree/admin/users/lifetime_stats' %> diff --git a/backend/app/views/spree/admin/users/new.html.erb b/backend/app/views/spree/admin/users/new.html.erb new file mode 100644 index 00000000000..7dd22670892 --- /dev/null +++ b/backend/app/views/spree/admin/users/new.html.erb @@ -0,0 +1,18 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:users), spree.admin_users_url %> / + <%= Spree.t(:new_user) %> +<% end %> + +
      + <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @user } %> +
      + +
      + <%= form_for [:admin, @user], url: spree.admin_users_url, method: :post do |f| %> + <%= render partial: 'form', locals: { f: f } %> + +
      + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
      + <% end %> +
      diff --git a/backend/app/views/spree/admin/users/orders.html.erb b/backend/app/views/spree/admin/users/orders.html.erb new file mode 100644 index 00000000000..8eade3f3722 --- /dev/null +++ b/backend/app/views/spree/admin/users/orders.html.erb @@ -0,0 +1,52 @@ +<%= render partial: 'spree/admin/users/sidebar', locals: { current: :orders } %> +<%= render partial: 'spree/admin/users/user_page_actions' %> + +<% content_for :page_title do %> + <%= link_to @user.email, spree.edit_admin_user_url(@user) %> / + <%= Spree.t(:"admin.user.order_history") %> +<% end %> + +
      + <%= paginate @orders %> + + <% if @orders.any? %> + <%# TODO add search interface %> + + + + + + + + + + + <% @orders.each do |order| %> + + + + + + + <% end %> + +
      <%= sort_link @search, :completed_at, I18n.t(:completed_at, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_completed_at_title'} %><%= sort_link @search, :number, I18n.t(:number, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_number_title'} %><%= sort_link @search, :state, I18n.t(:state, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_state_title'} %><%= sort_link @search, :total, I18n.t(:total, scope: 'activerecord.attributes.spree/order'), {}, {title: 'orders_total_title'} %>
      <%= order_time(order.completed_at) if order.completed_at %><%= link_to order.number, edit_admin_order_path(order) %> +
      <%= Spree.t("order_state.#{order.state.downcase}") %>
      + <% if order.payment_state %> +
      <%= link_to Spree.t("payment_states.#{order.payment_state}"), admin_order_payments_path(order) %>
      + <% end %> + <% if Spree::Order.checkout_step_names.include?(:delivery) && order.shipment_state %> +
      <%= Spree.t("shipment_states.#{order.shipment_state}") %>
      + <% end %> +
      <%= order.display_total.to_html %>
      + <% else %> +
      + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Order)) %>, + <%= link_to Spree.t(:add_one), spree.new_admin_order_path %>! +
      + <% end %> + + <%= paginate @orders %> +
      + +<%= render 'spree/admin/users/lifetime_stats' %> diff --git a/backend/app/views/spree/admin/variants/_autocomplete.js.erb b/backend/app/views/spree/admin/variants/_autocomplete.js.erb new file mode 100644 index 00000000000..b1f88338d7d --- /dev/null +++ b/backend/app/views/spree/admin/variants/_autocomplete.js.erb @@ -0,0 +1,21 @@ + diff --git a/backend/app/views/spree/admin/variants/_autocomplete_line_items_stock.js.erb b/backend/app/views/spree/admin/variants/_autocomplete_line_items_stock.js.erb new file mode 100644 index 00000000000..3af55703023 --- /dev/null +++ b/backend/app/views/spree/admin/variants/_autocomplete_line_items_stock.js.erb @@ -0,0 +1,63 @@ + diff --git a/backend/app/views/spree/admin/variants/_autocomplete_stock.js.erb b/backend/app/views/spree/admin/variants/_autocomplete_stock.js.erb new file mode 100644 index 00000000000..dde5400b209 --- /dev/null +++ b/backend/app/views/spree/admin/variants/_autocomplete_stock.js.erb @@ -0,0 +1,49 @@ + diff --git a/backend/app/views/spree/admin/variants/_form.html.erb b/backend/app/views/spree/admin/variants/_form.html.erb new file mode 100644 index 00000000000..be3afe9bc11 --- /dev/null +++ b/backend/app/views/spree/admin/variants/_form.html.erb @@ -0,0 +1,49 @@ +
      +
      +
      + <% @product.option_types.each do |option_type| %> +
      + <%= label :new_variant, option_type.presentation %> + <%= f.collection_select 'option_value_ids', option_type.option_values, :id, :presentation, + { include_blank: true }, { name: 'variant[option_value_ids][]', class: 'select2' } %> +
      + <% end %> + +
      + <%= f.label :sku, Spree.t(:sku) %> + <%= f.text_field :sku, class: 'form-control' %> +
      + +
      + <%= f.label :price, Spree.t(:price) %> + <%= f.text_field :price, value: number_to_currency(@variant.price, unit: ''), class: 'form-control' %> +
      + +
      + <%= f.label :cost_price, Spree.t(:cost_price) %> + <%= f.text_field :cost_price, value: number_to_currency(@variant.cost_price, unit: ''), class: 'form-control' %> +
      + +
      + <%= f.label :tax_category_id, Spree.t(:tax_category) %> + <%= f.collection_select(:tax_category_id, @tax_categories, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %> +
      + +
      + + <%= f.label :discontinue_on, Spree.t(:discontinue_on) %> + <%= f.error_message_on :discontinue_on %> + <%= f.text_field :discontinue_on, value: datepicker_field_value(@variant.discontinue_on), class: 'datepicker form-control' %> +
      +
      +
      + +
      + <% [:weight, :height, :width, :depth].each do |field| %> +
      <%= f.label field, Spree.t(field) %> + <% value = number_with_precision(@variant.send(field), precision: 2) %> + <%= f.text_field field, value: value, class: 'form-control' %> +
      + <% end %> +
      +
      diff --git a/backend/app/views/spree/admin/variants/_split.js.erb b/backend/app/views/spree/admin/variants/_split.js.erb new file mode 100644 index 00000000000..323f939bc2c --- /dev/null +++ b/backend/app/views/spree/admin/variants/_split.js.erb @@ -0,0 +1,33 @@ + diff --git a/backend/app/views/spree/admin/variants/edit.html.erb b/backend/app/views/spree/admin/variants/edit.html.erb new file mode 100644 index 00000000000..50f249761a5 --- /dev/null +++ b/backend/app/views/spree/admin/variants/edit.html.erb @@ -0,0 +1,13 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :variants } %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @variant } %> + +<%= form_for [:admin, @product, @variant] do |f| %> +
      +
      + <%= render partial: 'form', locals: { f: f } %> +
      + + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
      +<% end %> diff --git a/backend/app/views/spree/admin/variants/index.html.erb b/backend/app/views/spree/admin/variants/index.html.erb new file mode 100644 index 00000000000..f409155e711 --- /dev/null +++ b/backend/app/views/spree/admin/variants/index.html.erb @@ -0,0 +1,65 @@ +<%= render partial: 'spree/admin/shared/product_tabs', locals: {current: :variants} %> + +<%# Place for new variant form %> +
      + +<% if @variants.any? %> + + + + + + + + + + + <% @variants.each do |variant| %> + data-hook="variants_row"> + + + + + + + <% end %> + <% unless @product.has_variants? %> + + + + <% end %> + +
      <%= Spree.t(:options) %><%= Spree.t(:price) %><%= Spree.t(:sku) %>
      + <% if can? :edit, variant %> + + <% end %> + <%= variant.options_text %><%= variant.display_price.to_html %><%= variant.sku %> + <%= link_to_edit(variant, no_text: true) if can?(:edit, variant) && !variant.deleted? %> + <%= link_to_delete(variant, no_text: true) if can?(:destroy, variant) && !variant.deleted? %> +
      <%= Spree.t(:none) %>
      +<% else %> +
      + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Variant)) %> + <% if can?(:create, Spree::Variant) && !@product.empty_option_values? %> + - <%= link_to(Spree.t(:add_one), spree.new_admin_product_variant_path(@product)) %>! + <% end %> +
      +<% end %> + +<% if @product.empty_option_values? %> + <% if can?(:modify, Spree::ProductOptionType) %> +

      + <%= Spree.t(:to_add_variants_you_must_first_define) %> + <%= link_to(Spree.t(:option_types), spree.admin_product_url(@product)) %> + <% if can?(:display, Spree::OptionType) && can?([:create, :display], Spree::OptionValue) %> + <%= Spree.t(:and) %> + <%= link_to Spree.t(:option_values), spree.admin_option_types_url %> + <% end %> +

      + <% end %> +<% else %> + <% content_for :page_actions do %> + <%= button_link_to(Spree.t(:new_variant), spree.new_admin_product_variant_url(@product), { remote: :true, icon: 'add', :'data-update' => 'new_variant', class: 'btn-success', id: 'new_var_link' }) if can? :create, Spree::Variant %> + <%= button_link_to (@deleted.blank? ? Spree.t(:show_deleted) : Spree.t(:show_active)), spree.admin_product_variants_url(@product, deleted: @deleted.blank? ? "on" : "off"), { class: 'btn-default', icon: 'filter' } %> + <% end %> +<% end %> diff --git a/backend/app/views/spree/admin/variants/new.html.erb b/backend/app/views/spree/admin/variants/new.html.erb new file mode 100644 index 00000000000..9bb773bea8a --- /dev/null +++ b/backend/app/views/spree/admin/variants/new.html.erb @@ -0,0 +1,11 @@ +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @variant } %> + +<%= form_for [:admin, @product, @variant] do |f| %> +
      +
      + <%= Spree.t(:new_variant) %> + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
      +
      +<% end %> diff --git a/backend/app/views/spree/admin/variants/new.js.erb b/backend/app/views/spree/admin/variants/new.js.erb new file mode 100644 index 00000000000..9ddfd699707 --- /dev/null +++ b/backend/app/views/spree/admin/variants/new.js.erb @@ -0,0 +1,3 @@ +$("#new_variant").html('<%= escape_javascript(render template: 'spree/admin/variants/new', formats: [:html], handlers: [:erb]) %>'); +$(".select2").select2(); +$("#new_var_link").hide(); diff --git a/backend/app/views/spree/admin/variants/update.js.erb b/backend/app/views/spree/admin/variants/update.js.erb new file mode 100644 index 00000000000..e4bc0cc8e04 --- /dev/null +++ b/backend/app/views/spree/admin/variants/update.js.erb @@ -0,0 +1 @@ +<%= @object.as_json %> diff --git a/backend/app/views/spree/admin/zones/_country_members.html.erb b/backend/app/views/spree/admin/zones/_country_members.html.erb new file mode 100644 index 00000000000..1909df21513 --- /dev/null +++ b/backend/app/views/spree/admin/zones/_country_members.html.erb @@ -0,0 +1,16 @@ +
      +
      +
      +

      + <%= Spree.t(:countries) %> +

      +
      + +
      + <%= zone_form.field_container :country_ids, class: ['form-group'] do %> + <%= zone_form.label :country_ids, Spree.t(:countries) %> + <%= zone_form.collection_select :country_ids, @countries, :id, :name, {}, { multiple: true, class: "select2" } %> + <% end %> +
      +
      +
      diff --git a/backend/app/views/spree/admin/zones/_form.html.erb b/backend/app/views/spree/admin/zones/_form.html.erb new file mode 100644 index 00000000000..534eff89f8c --- /dev/null +++ b/backend/app/views/spree/admin/zones/_form.html.erb @@ -0,0 +1,49 @@ +
      +
      +
      +
      +

      <%= Spree.t(:general_settings)%>

      +
      + +
      + <%= zone_form.field_container :name, class: ['form-group'] do %> + <%= zone_form.label :name, Spree.t(:name) %> + <%= zone_form.text_field :name, class: 'form-control' %> + <%= zone_form.error_message_on :name %> + <% end %> + + <%= zone_form.field_container :description, class: ['form-group'] do %> + <%= zone_form.label :description, Spree.t(:description) %> + <%= zone_form.text_field :description, class: 'form-control' %> + <% end %> + +
      + <%= label_tag "zone[#{:default_tax}" do %> + <%= zone_form.check_box :default_tax %> + <%= Spree.t(:default_tax_zone) %> + <% end %> +
      + +
      + <%= Spree.t(:type) %> +
      + <%= label_tag :country_based do %> + <%= zone_form.radio_button('kind', 'country', { id: 'country_based' }) %> + <%= Spree.t(:country_based) %> + <% end %> +
      +
      + <%= label_tag :state_based do %> + <%= zone_form.radio_button('kind', 'state', { id: 'state_based' }) %> + <%= Spree.t(:state_based) %> + <% end %> +
      +
      +
      +
      +
      +
      + <%= render partial: 'state_members', locals: { zone_form: zone_form }%> + <%= render partial: 'country_members', locals: { zone_form: zone_form } %> +
      +
      diff --git a/backend/app/views/spree/admin/zones/_state_members.html.erb b/backend/app/views/spree/admin/zones/_state_members.html.erb new file mode 100644 index 00000000000..ca80ba0f2fc --- /dev/null +++ b/backend/app/views/spree/admin/zones/_state_members.html.erb @@ -0,0 +1,16 @@ +
      +
      +
      +

      + <%= Spree.t(:states) %> +

      +
      + +
      + <%= zone_form.field_container :state_ids, class: ['form-group'] do %> + <%= zone_form.label :state_ids, Spree.t(:states) %> + <%= zone_form.collection_select :state_ids, @states, :id, :name, {}, { multiple: true, class: "select2" } %> + <% end %> +
      +
      +
      diff --git a/backend/app/views/spree/admin/zones/edit.html.erb b/backend/app/views/spree/admin/zones/edit.html.erb new file mode 100644 index 00000000000..737859dbfc7 --- /dev/null +++ b/backend/app/views/spree/admin/zones/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:zones), spree.admin_zones_url %> / + <%= @zone.name %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @zone } %> + +<%= form_for [:admin, @zone] do |zone_form| %> + <%= render partial: 'form', locals: { zone_form: zone_form } %> + + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/admin/zones/index.html.erb b/backend/app/views/spree/admin/zones/index.html.erb new file mode 100644 index 00000000000..9dfb7e092e5 --- /dev/null +++ b/backend/app/views/spree/admin/zones/index.html.erb @@ -0,0 +1,44 @@ +<% content_for :page_title do %> + <%= Spree.t(:zones) %> +<% end %> + +<% content_for :page_actions do %> + <%= button_link_to Spree.t(:new_zone), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_zone_link' %> +<% end if can? :create, Spree::Zone %> + +<%= paginate @zones %> + +<% if @zones.any? %> + + + + + + + + + + + <% @zones.each do |zone| %> + + + + + + + <% end %> + +
      <%= sort_link @search,:name, Spree.t(:name), title: 'zones_order_by_name_title' %> + <%= sort_link @search,:description, Spree.t(:description), {}, {title: 'zones_order_by_description_title'} %> + <%= Spree.t(:default_tax_zone) %>
      <%= zone.name %><%= zone.description %><%= zone.default_tax? ? Spree.t(:say_yes) : Spree.t(:say_no) %> + <%= link_to_edit(zone, no_text: true) if can? :edit, zone %> + <%= link_to_delete(zone, no_text: true) if can? :delete, zone %> +
      +<% else %> +
      + <%= Spree.t(:no_resource_found, resource: plural_resource_name(Spree::Zone)) %>, + <%= link_to(Spree.t(:add_one), new_object_url) if can? :create, Spree::Zone %>! +
      +<% end %> + +<%= paginate @zones %> diff --git a/backend/app/views/spree/admin/zones/new.html.erb b/backend/app/views/spree/admin/zones/new.html.erb new file mode 100644 index 00000000000..c09e5156d54 --- /dev/null +++ b/backend/app/views/spree/admin/zones/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :page_title do %> + <%= link_to Spree.t(:zones), spree.admin_zones_url %> / + <%= Spree.t(:new_zone) %> +<% end %> + +<%= render partial: 'spree/admin/shared/error_messages', locals: { target: @zone } %> + +<%= form_for [:admin, @zone] do |zone_form| %> + <%= render partial: 'form', locals: { zone_form: zone_form } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +<% end %> diff --git a/backend/app/views/spree/layouts/admin.html.erb b/backend/app/views/spree/layouts/admin.html.erb new file mode 100644 index 00000000000..e50704bfb25 --- /dev/null +++ b/backend/app/views/spree/layouts/admin.html.erb @@ -0,0 +1,83 @@ + + + + <%= render partial: 'spree/admin/shared/head' %> + + + + + <%#-------------------------------------------------%> + <%# Loading progress bars %> + <%#-------------------------------------------------%> +
      +
      +
      <%= Spree.t(:loading) %>...
      +
      <%= Spree.t(:loading) %>...
      +
      +
      + + <%#-------------------------------------------------%> + <%# Header navbar %> + <%#-------------------------------------------------%> + <%= render partial: 'spree/admin/shared/header' %> + + <%#-------------------------------------------------%> + <%# Main content %> + <%#-------------------------------------------------%> +
      +
      + + <%#-------------------------------------------------%> + <%# Sidebar %> + <%#-------------------------------------------------%> +
      + <%= render partial: 'spree/admin/shared/main_menu' %> + + <%= render partial: 'spree/admin/shared/version' %> +
      + + <%#-------------------------------------------------%> + <%# Content %> + <%#-------------------------------------------------%> +
      + + <%#-------------------------------------------------%> + <%# Content header (page title/actions) %> + <%#-------------------------------------------------%> + <%= render partial: 'spree/admin/shared/content_header' %> + +
      + <%#-------------------------------------------------%> + <%# Alerts %> + <%#-------------------------------------------------%> + <%= flash_alert(flash) %> + + <%#-------------------------------------------------%> + <%# Main content %> + <%#-------------------------------------------------%> +
      + <%= render partial: 'spree/admin/shared/table_filter' if content_for?(:table_filter)%> + <%= yield %> +
      + + <%#-------------------------------------------------%> + <%# Inner aside %> + <%#-------------------------------------------------%> + <% if content_for?(:sidebar) %> +
      + <%= render partial: 'spree/admin/shared/sidebar' %> +
      + <% end %> +
      + + +
      +
      +
      + + <%#-------------------------------------------------%> + <%# Insert footer scripts here %> + <%#-------------------------------------------------%> +
      + + diff --git a/backend/config/initializers/assets.rb b/backend/config/initializers/assets.rb new file mode 100644 index 00000000000..bea757265cd --- /dev/null +++ b/backend/config/initializers/assets.rb @@ -0,0 +1 @@ +Rails.application.config.assets.precompile += %w(admin/* credit_cards/credit_card.gif) diff --git a/backend/config/initializers/form_builder.rb b/backend/config/initializers/form_builder.rb new file mode 100644 index 00000000000..6d1fbcd8417 --- /dev/null +++ b/backend/config/initializers/form_builder.rb @@ -0,0 +1,14 @@ +# +# Allow some application_helper methods to be used in the scoped form_for manner +# +class ActionView::Helpers::FormBuilder + def field_container(method, options = {}, &block) + @template.field_container(@object_name, method, options, &block) + end + + def error_message_on(method, options = {}) + @template.error_message_on(@object_name, method, objectify_options(options)) + end +end + +ActionView::Base.field_error_proc = proc { |html_tag, _instance| "#{html_tag}".html_safe } diff --git a/backend/config/routes.rb b/backend/config/routes.rb new file mode 100644 index 00000000000..eb1fc527502 --- /dev/null +++ b/backend/config/routes.rb @@ -0,0 +1,187 @@ +Spree::Core::Engine.add_routes do + namespace :admin, path: Spree.admin_path do + resources :promotions do + resources :promotion_rules + resources :promotion_actions + member do + post :clone + end + end + + resources :promotion_categories, except: [:show] + + resources :zones + + resources :stores do + member do + post :set_default + end + end + + resources :countries do + resources :states + end + resources :states + resources :tax_categories + + resources :products do + resources :product_properties do + collection do + post :update_positions + end + end + resources :images do + collection do + post :update_positions + end + end + member do + post :clone + get :stock + end + resources :variants do + collection do + post :update_positions + end + end + resources :variants_including_master, only: [:update] + end + + resources :option_types do + collection do + post :update_positions + post :update_values_positions + end + end + + delete '/option_values/:id', to: 'option_values#destroy', as: :option_value + + resources :properties do + collection do + get :filtered + end + end + + delete '/product_properties/:id', to: 'product_properties#destroy', as: :product_property + + resources :prototypes do + member do + get :select + end + + collection do + get :available + end + end + + resources :orders, except: [:show] do + member do + get :cart + post :resend + get :open_adjustments + get :close_adjustments + put :approve + put :cancel + put :resume + get :store + put :set_store + end + + resources :state_changes, only: [:index] + + resource :customer, controller: 'orders/customer_details' + resources :customer_returns, only: [:index, :new, :edit, :create, :update] do + member do + put :refund + end + end + + resources :adjustments + resources :return_authorizations do + member do + put :fire + end + end + resources :payments do + member do + put :fire + end + + resources :log_entries + resources :refunds, only: [:new, :create, :edit, :update] + end + + resources :reimbursements, only: [:index, :create, :show, :edit, :update] do + member do + post :perform + end + end + end + + get '/return_authorizations', to: 'return_index#return_authorizations', as: :return_authorizations + get '/customer_returns', to: 'return_index#customer_returns', as: :customer_returns + + resource :general_settings do + collection do + post :clear_cache + end + end + + resources :return_items, only: [:update] + + resources :taxonomies do + collection do + post :update_positions + end + resources :taxons + end + + resources :taxons, only: [:index, :show] + + resources :reports, only: [:index] do + collection do + get :sales_total + post :sales_total + end + end + + resources :reimbursement_types + resources :refund_reasons, except: :show + resources :return_authorization_reasons, except: :show + + resources :shipping_methods + resources :shipping_categories + resources :stock_transfers, only: [:index, :show, :new, :create] + resources :stock_locations do + resources :stock_movements, except: [:edit, :update, :destroy] + collection do + post :transfer_stock + end + end + + resources :stock_items, only: [:create, :update, :destroy] + resources :store_credit_categories + resources :tax_rates + resources :payment_methods do + collection do + post :update_positions + end + end + resources :roles + + resources :users do + member do + get :addresses + put :addresses + put :clear_api_key + put :generate_api_key + get :items + get :orders + end + resources :store_credits + end + end + + spree_path = Rails.application.routes.url_helpers.try(:spree_path, trailing_slash: true) || '/' + get Spree.admin_path, to: redirect((spree_path + Spree.admin_path + '/orders').gsub('//', '/')), as: :admin +end diff --git a/backend/lib/generators/spree/backend/copy_views/copy_views_generator.rb b/backend/lib/generators/spree/backend/copy_views/copy_views_generator.rb new file mode 100644 index 00000000000..e4821757b3e --- /dev/null +++ b/backend/lib/generators/spree/backend/copy_views/copy_views_generator.rb @@ -0,0 +1,15 @@ +module Spree + module Backend + class CopyViewsGenerator < Rails::Generators::Base + desc 'Copies views from spree backend to your application' + + def self.source_paths + [File.expand_path('../../../../../app', __dir__)] + end + + def copy_views + directory 'views', './app/views' + end + end + end +end diff --git a/backend/lib/spree/backend.rb b/backend/lib/spree/backend.rb new file mode 100644 index 00000000000..30187a076db --- /dev/null +++ b/backend/lib/spree/backend.rb @@ -0,0 +1,13 @@ +require 'rails/all' +require 'jquery-rails' +require 'jquery-ui-rails' +require 'deface' +require 'select2-rails' + +require 'spree_core' +require 'spree_api' + +require 'spree/responder' +require 'spree/backend/action_callbacks' +require 'spree/backend/callbacks' +require 'spree/backend/engine' diff --git a/backend/lib/spree/backend/action_callbacks.rb b/backend/lib/spree/backend/action_callbacks.rb new file mode 100644 index 00000000000..cd5eece1952 --- /dev/null +++ b/backend/lib/spree/backend/action_callbacks.rb @@ -0,0 +1,25 @@ +module Spree + class ActionCallbacks + attr_reader :before_methods + attr_reader :after_methods + attr_reader :fails_methods + + def initialize + @before_methods = [] + @after_methods = [] + @fails_methods = [] + end + + def before(method) + @before_methods << method + end + + def after(method) + @after_methods << method + end + + def fails(method) + @fails_methods << method + end + end +end diff --git a/backend/lib/spree/backend/callbacks.rb b/backend/lib/spree/backend/callbacks.rb new file mode 100644 index 00000000000..69b4e183b45 --- /dev/null +++ b/backend/lib/spree/backend/callbacks.rb @@ -0,0 +1,47 @@ +module Spree + module Backend + module Callbacks + extend ActiveSupport::Concern + + module ClassMethods + attr_accessor :callbacks + + protected + + def new_action + custom_callback(:new_action) + end + + def create + custom_callback(:create) + end + + def update + custom_callback(:update) + end + + def destroy + custom_callback(:destroy) + end + + def custom_callback(action) + @callbacks ||= {} + @callbacks[action] ||= Spree::ActionCallbacks.new + end + end + + protected + + def invoke_callbacks(action, callback_type) + callbacks = self.class.callbacks || {} + return if callbacks[action].nil? + + case callback_type.to_sym + when :before then callbacks[action].before_methods.each { |method| send method } + when :after then callbacks[action].after_methods.each { |method| send method } + when :fails then callbacks[action].fails_methods.each { |method| send method } + end + end + end + end +end diff --git a/backend/lib/spree/backend/engine.rb b/backend/lib/spree/backend/engine.rb new file mode 100644 index 00000000000..b4eb609068d --- /dev/null +++ b/backend/lib/spree/backend/engine.rb @@ -0,0 +1,29 @@ +module Spree + module Backend + class Engine < ::Rails::Engine + config.middleware.use 'Spree::Backend::Middleware::SeoAssist' + + initializer 'spree.backend.environment', before: :load_config_initializers do |_app| + Spree::Backend::Config = Spree::BackendConfiguration.new + end + + # filter sensitive information during logging + initializer 'spree.params.filter' do |app| + app.config.filter_parameters += [:password, :password_confirmation, :number] + end + + # sets the manifests / assets to be precompiled, even when initialize_on_precompile is false + initializer 'spree.assets.precompile', group: :all do |app| + app.config.assets.paths << "#{Rails.root}/app/assets/fonts" + app.config.assets.precompile << /\.(?:svg|eot|woff|ttf)$/ + + app.config.assets.precompile += %w[ + spree/backend/all* + spree/backend/address_states.js + jquery.jstree/themes/spree/* + select2_locale* + ] + end + end + end +end diff --git a/backend/lib/spree_backend.rb b/backend/lib/spree_backend.rb new file mode 100644 index 00000000000..d3f12febec5 --- /dev/null +++ b/backend/lib/spree_backend.rb @@ -0,0 +1,3 @@ +require 'spree/backend' +require 'sprockets/rails' +require 'bootstrap-sass' diff --git a/backend/script/rails b/backend/script/rails new file mode 100755 index 00000000000..dbe7b350bf8 --- /dev/null +++ b/backend/script/rails @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. + +ENGINE_ROOT = File.expand_path('../..', __FILE__) +ENGINE_PATH = File.expand_path('../../lib/spree/backend/engine', __FILE__) + +require 'rails/all' +require 'rails/engine/commands' + diff --git a/backend/spec/controllers/spree/admin/adjustments_controller_controller_spec.rb b/backend/spec/controllers/spree/admin/adjustments_controller_controller_spec.rb new file mode 100644 index 00000000000..5e10d734f72 --- /dev/null +++ b/backend/spec/controllers/spree/admin/adjustments_controller_controller_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +module Spree + module Admin + describe AdjustmentsController, type: :controller do + stub_authorization! + + describe '#index' do + subject do + spree_get :index, order_id: order.to_param + end + + let!(:order) { create(:order) } + let!(:adjustment_1) { create(:adjustment, order: order) } + + before do + create(:adjustment, order: order, eligible: false) # adjustment_2 + subject + end + + it 'returns 200 status' do + expect(response.status).to eq 200 + end + + it 'loads the order' do + expect(assigns(:order)).to eq order + end + + it 'returns only eligible adjustments' do + expect(assigns(:adjustments)).to match_array([adjustment_1]) + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/base_controller_spec.rb b/backend/spec/controllers/spree/admin/base_controller_spec.rb new file mode 100644 index 00000000000..cb86ed9fd17 --- /dev/null +++ b/backend/spec/controllers/spree/admin/base_controller_spec.rb @@ -0,0 +1,46 @@ +# Spree's rpsec controller tests get the Spree::ControllerHacks +# we don't need those for the anonymous controller here, so +# we call process directly instead of get +require 'spec_helper' + +describe Spree::Admin::BaseController, type: :controller do + controller(Spree::Admin::BaseController) do + def index + authorize! :update, Spree::Order + render plain: 'test' + end + end + + context 'unauthorized request' do + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(nil) + end + + it 'redirects to root' do + allow(controller).to receive_message_chain(:spree, :root_path).and_return('/root') + get :index + expect(response).to redirect_to '/root' + end + end + + context '#generate_api_key' do + let(:user) { mock_model(Spree.user_class, has_spree_role?: true) } + + before do + allow(controller).to receive(:authorize_admin).and_return(true) + allow(controller).to receive(:try_spree_current_user) { user } + end + + it 'generates the API key for a user when they visit' do + expect(user).to receive(:spree_api_key).and_return(nil) + expect(user).to receive(:generate_spree_api_key!) + get :index + end + + it 'does not attempt to regenerate the API key if the key is already set' do + expect(user).to receive(:spree_api_key).and_return('fake') + expect(user).not_to receive(:generate_spree_api_key!) + get :index + end + end +end diff --git a/backend/spec/controllers/spree/admin/customer_returns_controller_spec.rb b/backend/spec/controllers/spree/admin/customer_returns_controller_spec.rb new file mode 100644 index 00000000000..ace537d9eac --- /dev/null +++ b/backend/spec/controllers/spree/admin/customer_returns_controller_spec.rb @@ -0,0 +1,185 @@ +require 'spec_helper' + +module Spree + module Admin + describe CustomerReturnsController, type: :controller do + stub_authorization! + + describe '#index' do + subject do + spree_get :index, order_id: customer_return.order.to_param + end + + let(:order) { customer_return.order } + let(:customer_return) { create(:customer_return) } + + before { subject } + + it 'loads the order' do + expect(assigns(:order)).to eq order + end + + it 'loads the customer return' do + expect(assigns(:customer_returns)).to include(customer_return) + end + end + + describe '#new' do + subject do + spree_get :new, order_id: order.to_param + end + + let(:order) { create(:shipped_order, line_items_count: 1) } + let!(:rma) { create :return_authorization, order: order, return_items: [create(:return_item, inventory_unit: order.inventory_units.first)] } + + before do + create(:reimbursement_type, active: false) # inactive_reimbursement_type + create(:reimbursement_type) # first_active_reimbursement_type + create(:reimbursement_type) # second_active_reimbursement_type + end + + it 'loads the order' do + subject + expect(assigns(:order)).to eq order + end + + it 'creates a new customer return' do + subject + expect(assigns(:customer_return)).not_to be_persisted + end + + context 'with previous customer return' do + let(:order) { create(:shipped_order, line_items_count: 4) } + let(:rma) { create(:return_authorization, order: order) } + + let!(:rma_return_item) { create(:return_item, return_authorization: rma, inventory_unit: order.inventory_units.first) } + let!(:customer_return_return_item) { create(:return_item, return_authorization: rma, inventory_unit: order.inventory_units.last) } + + context 'there is a return item associated with an rma but not a customer return' do + before do + create(:customer_return_without_return_items, return_items: [customer_return_return_item]) # previous_customer_return + subject + end + + it 'loads the persisted rma return items' do + expect(assigns(:rma_return_items).all?(&:persisted?)).to eq true + end + + it 'has one rma return item' do + expect(assigns(:rma_return_items)).to include(rma_return_item) + end + end + end + end + + describe '#edit' do + subject do + spree_get :edit, order_id: order.to_param, id: customer_return.to_param + end + + let(:order) { customer_return.order } + let(:customer_return) { create(:customer_return, line_items_count: 3) } + + let!(:accepted_return_item) { customer_return.return_items.order('id').first.tap(&:accept!) } + let!(:rejected_return_item) { customer_return.return_items.order('id').second.tap(&:reject!) } + let!(:manual_intervention_return_item) { customer_return.return_items.order('id').third.tap(&:require_manual_intervention!) } + + before { subject } + + it 'loads the order' do + expect(assigns(:order)).to eq order + end + + it 'loads the customer return' do + expect(assigns(:customer_return)).to eq customer_return + end + + it 'loads the accepted return items' do + expect(assigns(:accepted_return_items)).to eq [accepted_return_item] + end + + it 'loads the rejected return items' do + expect(assigns(:rejected_return_items)).to eq [rejected_return_item] + end + + it 'loads the return items that require manual intervention' do + expect(assigns(:manual_intervention_return_items)).to eq [manual_intervention_return_item] + end + + it 'loads the return items that are still pending' do + expect(assigns(:pending_return_items)).to eq [] + end + + it 'loads the reimbursements that are still pending' do + expect(assigns(:pending_reimbursements)).to eq [] + end + end + + describe '#create' do + subject do + spree_post :create, customer_return_params + end + + let(:order) { create(:shipped_order, line_items_count: 1) } + let!(:return_authorization) { create :return_authorization, order: order, return_items: [create(:return_item, inventory_unit: order.inventory_units.shipped.last)] } + + context 'valid customer return' do + let(:stock_location) { order.shipments.last.stock_location } + + let(:customer_return_params) do + { + order_id: order.to_param, + customer_return: { + stock_location_id: stock_location.id, + return_items_attributes: { + '0' => { + id: return_authorization.return_items.first.id, + returned: '1', + 'pre_tax_amount' => '15.99', + inventory_unit_id: order.inventory_units.shipped.last.id + } + } + } + } + end + + it 'creates a customer return' do + expect { subject }.to change { Spree::CustomerReturn.count }.by(1) + end + + it 'redirects to the index page' do + subject + expect(response).to redirect_to(spree.edit_admin_order_customer_return_path(order, assigns(:customer_return))) + end + end + + context 'invalid customer return' do + let(:customer_return_params) do + { + order_id: order.to_param, + customer_return: { + stock_location_id: '', + return_items_attributes: { + '0' => { + returned: '1', + 'pre_tax_amount' => '15.99', + inventory_unit_id: order.inventory_units.shipped.last.id + } + } + } + } + end + + it "doesn't create a customer return" do + expect { subject }.not_to change { Spree::CustomerReturn.count } + end + + it 'renders the new page' do + subject + expect(response).to render_template(:new) + end + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/general_settings_controller_spec.rb b/backend/spec/controllers/spree/admin/general_settings_controller_spec.rb new file mode 100644 index 00000000000..56082b9641b --- /dev/null +++ b/backend/spec/controllers/spree/admin/general_settings_controller_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Spree::Admin::GeneralSettingsController, type: :controller do + let(:user) { create(:user) } + + before do + allow(controller).to receive_messages spree_current_user: user + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + describe '#clear_cache' do + subject { spree_post :clear_cache } + + shared_examples 'a HTTP 204 response' do + it 'grant access to users with an admin role' do + subject + expect(response.status).to eq(204) + end + end + + context 'when no callback' do + it_behaves_like 'a HTTP 204 response' + end + + context 'when callback implemented' do + Spree::Admin::GeneralSettingsController.class_eval do + custom_callback(:clear_cache).after :foo + + def foo + # Make a call to Akamai, CloudFlare, etc invalidation.... + end + end + + before do + expect(controller).to receive(:foo).once + end + + it_behaves_like 'a HTTP 204 response' + end + end +end diff --git a/backend/spec/controllers/spree/admin/missing_products_controller_spec.rb b/backend/spec/controllers/spree/admin/missing_products_controller_spec.rb new file mode 100644 index 00000000000..d41f3566707 --- /dev/null +++ b/backend/spec/controllers/spree/admin/missing_products_controller_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' +# This test exists in this file because in the standard admin/products_controller spec +# There is the stub_authorization call. This call is not triggered for this test because +# the load_resource filter in Spree::Admin::ResourceController is prepended to the filter chain +# this means this call is triggered before the authorize_admin call and in this case +# the load_resource filter halts the request meaning authorize_admin is not called at all. +describe Spree::Admin::ProductsController, type: :controller do + stub_authorization! + + # Regression test for GH #538 + it 'cannot find a non-existent product' do + spree_get :edit, id: 'non-existent-product' + expect(response).to redirect_to(spree.admin_products_path) + expect(flash[:error]).to eql('Product is not found') + end +end diff --git a/backend/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb b/backend/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb new file mode 100644 index 00000000000..fc7016e30d7 --- /dev/null +++ b/backend/spec/controllers/spree/admin/orders/customer_details_controller_spec.rb @@ -0,0 +1,208 @@ +require 'spec_helper' +require 'cancan' +require 'spree/testing_support/bar_ability' + +describe Spree::Admin::Orders::CustomerDetailsController, type: :controller do + context 'with authorization' do + stub_authorization! + + let(:user) { mock_model(Spree.user_class) } + + let(:order) do + mock_model( + Spree::Order, + total: 100, + number: 'R123456789', + billing_address: mock_model(Spree::Address) + ) + end + + before do + allow(Spree::Order).to receive_message_chain(:includes, find_by!: order) + end + + describe '#update' do + let(:attributes) do + { + order_id: order.number, + order: { + email: '', + use_billing: '', + bill_address_attributes: { firstname: 'john' }, + ship_address_attributes: { firstname: 'john' }, + user_id: user.id.to_s + }, + guest_checkout: 'true' + } + end + + def send_request(params = {}) + spree_put :update, params + end + + context 'using guest checkout' do + context 'having valid parameters' do + before do + allow(order).to receive_messages(update_attributes: true) + allow(order).to receive_messages(next: false) + allow(order).to receive_messages(address?: false) + allow(order).to receive_messages(refresh_shipment_rates: true) + end + + context 'having successful response' do + before { send_request(attributes) } + + it { expect(response).to have_http_status(302) } + it { expect(response).to redirect_to(edit_admin_order_url(order)) } + end + + context 'with correct method flow' do + after { send_request(attributes) } + + it { expect(order).to receive(:update_attributes).with(ActionController::Parameters.new(attributes[:order]).permit(permitted_order_attributes)) } + it { expect(order).not_to receive(:next) } + it { expect(order).to receive(:address?) } + it 'does refresh the shipment rates with all shipping methods' do + expect(order).to receive(:refresh_shipment_rates). + with(Spree::ShippingMethod::DISPLAY_ON_BACK_END) + end + it { expect(controller).to receive(:load_order).and_call_original } + it { expect(controller).to receive(:guest_checkout?).twice.and_call_original } + it { expect(controller).not_to receive(:load_user).and_call_original } + end + end + + context 'having invalid parameters' do + before do + allow(order).to receive_messages(update_attributes: false) + end + + context 'having failure response' do + before { send_request(attributes) } + + it { expect(response).to render_template(:edit) } + end + + context 'with correct method flow' do + after { send_request(attributes) } + + it { expect(order).to receive(:update_attributes).with(ActionController::Parameters.new(attributes[:order]).permit(permitted_order_attributes)) } + it { expect(controller).to receive(:load_order).and_call_original } + it { expect(controller).to receive(:guest_checkout?).and_call_original } + it { expect(controller).not_to receive(:load_user).and_call_original } + end + end + end + + context 'without using guest checkout' do + let(:changed_attributes) { attributes.merge(guest_checkout: 'false') } + + context 'having valid parameters' do + before do + allow(Spree.user_class).to receive(:find_by).and_return(user) + allow(order).to receive_messages(update_attributes: true) + allow(order).to receive_messages(next: false) + allow(order).to receive_messages(address?: false) + allow(order).to receive_messages(refresh_shipment_rates: true) + allow(order).to receive_messages(associate_user!: true) + allow(controller).to receive(:guest_checkout?).and_return(false) + allow(order).to receive(:associate_user!) + end + + context 'having successful response' do + before { send_request(changed_attributes) } + + it { expect(response).to have_http_status(302) } + it { expect(response).to redirect_to(edit_admin_order_url(order)) } + end + + context 'with correct method flow' do + after { send_request(changed_attributes) } + + it { expect(order).to receive(:update_attributes).with(ActionController::Parameters.new(attributes[:order]).permit(permitted_order_attributes)) } + it { expect(order).to receive(:associate_user!).with(user, order.email.blank?) } + it { expect(order).not_to receive(:next) } + it { expect(order).to receive(:address?) } + it 'does refresh the shipment rates with all shipping methods' do + expect(order).to receive(:refresh_shipment_rates). + with(Spree::ShippingMethod::DISPLAY_ON_BACK_END) + end + it { expect(controller).to receive(:load_order).and_call_original } + it { expect(controller).to receive(:guest_checkout?).twice.and_call_original } + it { expect(controller).to receive(:load_user).and_call_original } + end + end + + context 'having invalid parameters' do + before do + allow(Spree.user_class).to receive(:find_by).and_return(false) + allow(controller).to receive(:guest_checkout?).and_return(false) + end + + context 'having failure response' do + before { send_request(changed_attributes) } + + it { expect(response).to render_template(:edit) } + end + + context 'with correct method flow' do + after { send_request(changed_attributes) } + + it { expect(order).not_to receive(:update_attributes).with(ActionController::Parameters.new(attributes[:order]).permit(permitted_order_attributes)) } + it { expect(controller).to receive(:load_order).and_call_original } + it { expect(controller).to receive(:guest_checkout?).and_call_original } + it { expect(controller).to receive(:load_user).and_call_original } + end + end + + describe '#load_user' do + context 'having valid parameters' do + before do + allow(Spree.user_class).to receive(:find_by).and_return(user) + allow(order).to receive_messages(update_attributes: true) + allow(order).to receive_messages(next: false) + allow(order).to receive_messages(address?: false) + allow(order).to receive_messages(refresh_shipment_rates: true) + allow(order).to receive_messages(associate_user!: true) + allow(controller).to receive(:guest_checkout?).and_return(false) + allow(order).to receive(:associate_user!) + end + + it 'expects to assign user' do + send_request(changed_attributes) + expect(assigns[:user]).to eq(user) + end + + context 'with correct method flow' do + after { send_request(changed_attributes) } + + it { expect(Spree.user_class).to receive(:find_by).with(id: user.id.to_s).and_return(user) } + end + end + + context 'with invalid parameters' do + before do + allow(Spree.user_class).to receive(:find_by).and_return(nil) + allow(controller).to receive(:guest_checkout?).and_return(false) + end + + it 'expects to not assign user' do + send_request(changed_attributes) + expect(assigns[:user]).not_to eq(user) + end + + context 'with correct method flow' do + after { send_request(changed_attributes) } + + it { expect(Spree.user_class).to receive(:find_by).with(id: user.id.to_s).and_return(nil) } + it 'expects user class to receive find_by with email' do + expect(Spree.user_class).to receive(:find_by). + with(email: changed_attributes[:order][:email]).and_return(nil) + end + end + end + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/orders_controller_spec.rb b/backend/spec/controllers/spree/admin/orders_controller_spec.rb new file mode 100644 index 00000000000..f99326c6d1e --- /dev/null +++ b/backend/spec/controllers/spree/admin/orders_controller_spec.rb @@ -0,0 +1,416 @@ +require 'spec_helper' +require 'cancan' +require 'spree/testing_support/bar_ability' + +# Ability to test access to specific model instances +class OrderSpecificAbility + include CanCan::Ability + + def initialize(_user) + can [:admin, :manage], Spree::Order, number: 'R987654321' + end +end + +describe Spree::Admin::OrdersController, type: :controller do + context 'with authorization' do + stub_authorization! + + let(:order) do + mock_model( + Spree::Order, + completed?: true, + total: 100, + number: 'R123456789', + all_adjustments: adjustments, + billing_address: mock_model(Spree::Address) + ) + end + + let(:adjustments) { double('adjustments') } + let(:display_value) { Spree::ShippingMethod::DISPLAY_ON_BACK_END } + + before do + request.env['HTTP_REFERER'] = 'http://localhost:3000' + # ensure no respond_overrides are in effect + Spree::BaseController.spree_responders[:OrdersController].clear if Spree::BaseController.spree_responders[:OrdersController].present? + + allow(Spree::Order).to receive_message_chain(:includes, find_by!: order) + end + + context '#approve' do + it 'approves an order' do + expect(order).to receive(:approved_by).with(controller.try_spree_current_user) + spree_put :approve, id: order.number + expect(flash[:success]).to eq Spree.t(:order_approved) + end + end + + context '#cancel' do + it 'cancels an order' do + expect(order).to receive(:canceled_by).with(controller.try_spree_current_user) + spree_put :cancel, id: order.number + expect(flash[:success]).to eq Spree.t(:order_canceled) + end + end + + context '#resume' do + it 'resumes an order' do + expect(order).to receive(:resume!) + spree_put :resume, id: order.number + expect(flash[:success]).to eq Spree.t(:order_resumed) + end + end + + context 'pagination' do + it 'can page through the orders' do + spree_get :index, page: 2, per_page: 10 + expect(assigns[:orders].offset_value).to eq(10) + expect(assigns[:orders].limit_value).to eq(10) + end + end + + describe '#store' do + subject do + spree_get :store, id: cart_order.number + end + + let(:cart_order) { create(:order_with_line_items) } + + it 'displays a page with stores select tag' do + expect(subject).to render_template :store + end + end + + # Test for #3346 + context '#new' do + it 'a new order has the current user assigned as a creator' do + spree_get :new + expect(assigns[:order].created_by).to eq(controller.try_spree_current_user) + end + end + + # Regression test for #3684 + describe '#edit' do + before do + allow(controller).to receive(:can_not_transition_without_customer_info) + end + + after do + spree_get :edit, id: order.number + end + + it { expect(controller).to receive(:can_not_transition_without_customer_info) } + + context 'when order is not completed' do + before do + allow(order).to receive(:completed?).and_return(false) + allow(order).to receive(:refresh_shipment_rates).with(display_value).and_return(true) + end + + it { expect(order).to receive(:completed?).and_return(false) } + it { expect(order).to receive(:refresh_shipment_rates).with(display_value).and_return(true) } + end + + context 'when order is completed' do + before do + allow(order).to receive(:completed?).and_return(true) + end + + it { expect(order).to receive(:completed?).and_return(true) } + end + end + + describe '#cart' do + def send_request + spree_get :cart, id: order.number + end + + context 'when order is not completed' do + before do + allow(order).to receive(:completed?).and_return(false) + allow(order).to receive(:refresh_shipment_rates).with(display_value).and_return(true) + end + + context 'when order has shipped shipments' do + before do + allow(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) + allow(controller).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(order).to receive(:completed?).and_return(false) } + it { expect(order).to receive(:refresh_shipment_rates).with(display_value).and_return(true) } + it { expect(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to be_redirect } + it { expect(response).to redirect_to(edit_admin_order_url(order)) } + end + end + + context 'when order has no shipped shipments' do + before do + allow(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(false) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(order).to receive(:completed?).and_return(false) } + it { expect(order).to receive(:refresh_shipment_rates).with(display_value).and_return(true) } + it { expect(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(false) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to render_template :cart } + end + end + end + + context 'when order is completed' do + before do + allow(order).to receive(:completed?).and_return(true) + end + + context 'when order has shipped shipments' do + before do + allow(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) + allow(controller).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(true) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to be_redirect } + it { expect(response).to redirect_to(edit_admin_order_url(order)) } + end + end + + context 'when order has no shipped shipments' do + before do + allow(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(false) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(order).to receive_message_chain(:shipments, :shipped, :exists?).and_return(false) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to render_template :cart } + end + end + end + end + + # Test for #3919 + context 'search' do + let(:user) { create(:user) } + + before do + allow(controller).to receive_messages spree_current_user: user + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + + create(:completed_order_with_totals) + expect(Spree::Order.count).to eq 1 + end + + def send_request + spree_get :index, q: { + line_items_variant_id_in: Spree::Order.first.variants.map(&:id) + } + end + + it 'does not display duplicate results' do + send_request + expect(assigns[:orders].map(&:number).count).to eq 1 + end + + it 'preloads users' do + expect(Spree::Order).to receive(:preload).with(:user).and_return(Spree::Order.all) + send_request + end + end + + context '#open_adjustments' do + let(:closed) { double('closed_adjustments') } + + before do + allow(adjustments).to receive(:finalized).and_return(closed) + allow(closed).to receive(:update_all) + end + + it 'changes all the closed adjustments to open' do + expect(adjustments).to receive(:finalized).and_return(closed) + expect(closed).to receive(:update_all).with(state: 'open') + spree_post :open_adjustments, id: order.number + end + + it 'sets the flash success message' do + spree_post :open_adjustments, id: order.number + expect(flash[:success]).to eql('All adjustments successfully opened!') + end + + context 'when referer' do + before do + request.env['HTTP_REFERER'] = '/' + end + + it 'redirects back' do + spree_post :open_adjustments, id: order.number + expect(response).to redirect_to('/') + end + end + + context 'when no referer' do + before do + request.env['HTTP_REFERER'] = nil + end + + it 'refirects to fallback location' do + spree_post :open_adjustments, id: order.number + expect(response).to redirect_to(admin_order_adjustments_url(order)) + end + end + end + + context '#close_adjustments' do + let(:open) { double('open_adjustments') } + + before do + allow(adjustments).to receive(:not_finalized).and_return(open) + allow(open).to receive(:update_all) + end + + it 'changes all the open adjustments to closed' do + expect(adjustments).to receive(:not_finalized).and_return(open) + expect(open).to receive(:update_all).with(state: 'closed') + spree_post :close_adjustments, id: order.number + end + + it 'sets the flash success message' do + spree_post :close_adjustments, id: order.number + expect(flash[:success]).to eql('All adjustments successfully closed!') + end + + context 'when referer' do + before do + request.env['HTTP_REFERER'] = '/' + end + + it 'redirects back' do + spree_post :close_adjustments, id: order.number + expect(response).to redirect_to('/') + end + end + + context 'when no referer' do + before do + request.env['HTTP_REFERER'] = nil + end + + it 'refirects to fallback location' do + spree_post :close_adjustments, id: order.number + expect(response).to redirect_to(admin_order_adjustments_url(order)) + end + end + end + end + + context '#authorize_admin' do + let(:user) { create(:user) } + let(:order) { create(:completed_order_with_totals, number: 'R987654321') } + + def with_ability(ability) + Spree::Ability.register_ability(ability) + yield + ensure + Spree::Ability.remove_ability(ability) + end + + before do + allow(Spree::Order).to receive_messages find: order + allow(controller).to receive_messages spree_current_user: user + end + + it 'grants access to users with an admin role' do + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + spree_post :index + expect(response).to render_template :index + end + + it 'grants access to users with an bar role' do + with_ability(BarAbility) do + user.spree_roles << Spree::Role.find_or_create_by(name: 'bar') + spree_post :index + expect(response).to render_template :index + end + end + + it 'denies access to users with an bar role' do + with_ability(BarAbility) do + allow(order).to receive(:update_attributes).and_return true + allow(order).to receive(:user).and_return Spree.user_class.new + allow(order).to receive(:token).and_return nil + user.spree_roles.clear + user.spree_roles << Spree::Role.find_or_create_by(name: 'bar') + spree_put :update, id: order.number + expect(response).to redirect_to(spree.forbidden_path) + end + end + + it 'denies access to users without an admin role' do + allow(user).to receive_messages has_spree_role?: false + spree_post :index + expect(response).to redirect_to(spree.forbidden_path) + end + + it 'denies access to not signed in users' do + allow(controller).to receive_messages spree_current_user: nil + spree_get :index + expect(response).to redirect_to('/') + end + + it 'restricts returned order(s) on index when using OrderSpecificAbility' do + number = order.number + + create_list(:completed_order_with_totals, 3) + expect(Spree::Order.complete.count).to eq 4 + + with_ability(OrderSpecificAbility) do + allow(user).to receive_messages has_spree_role?: false + spree_get :index + expect(response).to render_template :index + expect(assigns['orders'].distinct(false).size).to eq 1 + expect(assigns['orders'].first.number).to eq number + expect(Spree::Order.accessible_by(Spree::Ability.new(user), :index).pluck(:number)).to eq [number] + end + end + end + + context 'order number not given' do + stub_authorization! + + it 'raise active record not found' do + expect do + spree_get :edit, id: 99_999_999 + end.to raise_error ActiveRecord::RecordNotFound + end + end +end diff --git a/backend/spec/controllers/spree/admin/payment_methods_controller_spec.rb b/backend/spec/controllers/spree/admin/payment_methods_controller_spec.rb new file mode 100644 index 00000000000..e7a447d97a3 --- /dev/null +++ b/backend/spec/controllers/spree/admin/payment_methods_controller_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +module Spree + class GatewayWithPassword < PaymentMethod + preference :password, :string, default: 'password' + end + + describe Admin::PaymentMethodsController, type: :controller do + stub_authorization! + + let(:payment_method) { GatewayWithPassword.create!(name: 'Bogus', preferred_password: 'haxme') } + + # regression test for #2094 + it 'does not clear password on update' do + expect(payment_method.preferred_password).to eq('haxme') + spree_put :update, id: payment_method.id, payment_method: { type: payment_method.class.to_s, preferred_password: '' } + expect(response).to redirect_to(spree.edit_admin_payment_method_path(payment_method)) + + payment_method.reload + expect(payment_method.preferred_password).to eq('haxme') + end + + it 'saves payment method preferences on update' do + spree_put :update, + id: payment_method.id, + payment_method: { + type: payment_method.class.to_s, + name: 'Bogus' + }, + gateway_with_password: { + preferred_password: 'abc' + } + + payment_method.reload + expect(payment_method.preferred_password).to eq('abc') + end + + context 'tries to save invalid payment' do + it "doesn't break, responds nicely" do + expect do + spree_post :create, payment_method: { name: '', type: 'Spree::Gateway::Bogus' } + end.not_to raise_error + end + end + + it 'can create a payment method of a valid type' do + expect do + spree_post :create, payment_method: { name: 'Test Method', type: 'Spree::Gateway::Bogus' } + end.to change(Spree::PaymentMethod, :count).by(1) + + expect(response).to be_redirect + expect(response).to redirect_to spree.edit_admin_payment_method_path(assigns(:payment_method)) + end + + it 'can not create a payment method of an invalid type' do + expect do + spree_post :create, payment_method: { name: 'Invalid Payment Method', type: 'Spree::InvalidType' } + end.to change(Spree::PaymentMethod, :count).by(0) + + expect(response).to be_redirect + expect(response).to redirect_to spree.new_admin_payment_method_path + end + end +end diff --git a/backend/spec/controllers/spree/admin/payments_controller_spec.rb b/backend/spec/controllers/spree/admin/payments_controller_spec.rb new file mode 100644 index 00000000000..30c9cd31058 --- /dev/null +++ b/backend/spec/controllers/spree/admin/payments_controller_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +module Spree + module Admin + describe PaymentsController, type: :controller do + stub_authorization! + + let(:order) { create(:order) } + + context 'with a valid credit card' do + let(:order) { create(:order_with_line_items, state: 'payment') } + let(:payment_method) { create(:credit_card_payment_method, display_on: 'back_end') } + + before do + attributes = { + order_id: order.number, + card: 'new', + payment: { + amount: order.total, + payment_method_id: payment_method.id.to_s, + source_attributes: { + name: 'Test User', + number: '4111 1111 1111 1111', + expiry: "09 / #{Time.current.year + 1}", + verification_value: '123' + } + } + } + spree_post :create, attributes + end + + it 'processes payment correctly' do + expect(order.payments.count).to eq(1) + expect(response).to redirect_to(spree.admin_order_payments_path(order)) + expect(order.reload.state).to eq('complete') + end + + # Regression for #4768 + it 'doesnt process the same payment twice' do + expect(Spree::LogEntry.where(source: order.payments.first).count).to eq(1) + end + end + + # Regression test for #3233 + context 'with a backend payment method' do + before do + @payment_method = create(:check_payment_method, display_on: 'back_end') + end + + it 'loads backend payment methods' do + spree_get :new, order_id: order.number + expect(response.status).to eq(200) + expect(assigns[:payment_methods]).to include(@payment_method) + end + end + + context 'order has billing address' do + before do + order.bill_address = create(:address) + order.save! + end + + context 'order does not have payments' do + it 'redirect to new payments page' do + spree_get :index, amount: 100, order_id: order.number + expect(response).to redirect_to(spree.new_admin_order_payment_path(order)) + end + end + + context 'order has payments' do + before do + order.payments << create(:payment, amount: order.total, order: order, state: 'completed') + end + + it 'shows the payments page' do + spree_get :index, amount: 100, order_id: order.number + expect(response.code).to eq '200' + end + end + end + + context 'order does not have a billing address' do + before do + order.bill_address = nil + order.save + end + + it 'redirects to the customer details page' do + spree_get :index, amount: 100, order_id: order.number + expect(response).to redirect_to(spree.edit_admin_order_customer_path(order)) + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/products_controller_spec.rb b/backend/spec/controllers/spree/admin/products_controller_spec.rb new file mode 100644 index 00000000000..67864f189ab --- /dev/null +++ b/backend/spec/controllers/spree/admin/products_controller_spec.rb @@ -0,0 +1,157 @@ +require 'spec_helper' + +describe Spree::Admin::ProductsController, type: :controller do + stub_authorization! + + context '#index' do + let(:ability_user) { stub_model(Spree::LegacyUser, has_spree_role?: true) } + + # Regression test for #1259 + it 'can find a product by SKU' do + product = create(:product, sku: 'ABC123') + spree_get :index, q: { sku_start: 'ABC123' } + expect(assigns[:collection]).not_to be_empty + expect(assigns[:collection]).to include(product) + end + end + + # regression test for #1370 + context 'adding properties to a product' do + let!(:product) { create(:product) } + + specify do + spree_put :update, id: product.to_param, product: { product_properties_attributes: { '1' => { property_name: 'Foo', value: 'bar' } } } + expect(flash[:success]).to eq("Product #{product.name.inspect} has been successfully updated!") + end + end + + # regression test for #801 + describe '#destroy' do + let(:product) { mock_model(Spree::Product) } + let(:products) { double(ActiveRecord::Relation) } + + def send_request + spree_delete :destroy, id: product, format: :js + end + + context 'will successfully destroy product' do + before do + allow(Spree::Product).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_return(product) + allow(product).to receive(:destroy).and_return(true) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(Spree::Product).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find).with(product.id.to_s).and_return(product) } + it { expect(product).to receive(:destroy).and_return(true) } + end + + describe 'assigns' do + before { send_request } + + it { expect(assigns(:product)).to eq(product) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to have_http_status(:ok) } + it { expect(flash[:success]).to eq(Spree.t('notice_messages.product_deleted')) } + end + end + + context 'will not successfully destroy product' do + let(:error_msg) { 'Failed to delete' } + + before do + allow(Spree::Product).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_return(product) + allow(product).to receive_message_chain(:errors, :full_messages).and_return([error_msg]) + allow(product).to receive(:destroy).and_return(false) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(Spree::Product).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find).with(product.id.to_s).and_return(product) } + it { expect(product).to receive(:destroy).and_return(false) } + end + + describe 'assigns' do + before { send_request } + + it { expect(assigns(:product)).to eq(product) } + end + + describe 'response' do + before { send_request } + + it { expect(response).to have_http_status(:ok) } + + it 'set flash error' do + expected_error = Spree.t('notice_messages.product_not_deleted', error: error_msg) + expect(flash[:error]).to eq(expected_error) + end + end + end + end + + describe '#clone' do + subject(:send_request) do + spree_post :clone, id: product, format: :js + end + + let!(:product) { create(:custom_product, name: 'MyProduct', sku: 'MySku') } + let(:product2) { create(:custom_product, name: 'COPY OF MyProduct', sku: 'COPY OF MySku') } + let(:variant) { create(:master_variant, name: 'COPY OF MyProduct', sku: 'COPY OF MySku', created_at: product.created_at - 1.day) } + + context 'will successfully clone product' do + before do + Timecop.freeze(Date.today + 30) + allow(product).to receive(:duplicate).and_return(product2) + send_request + end + + after do + Timecop.return + end + + describe 'response' do + it { expect(response).to have_http_status(:found) } + it { expect(response).to be_redirect } + it { expect(flash[:success]).to eq(Spree.t('notice_messages.product_cloned')) } + end + end + + context 'will not successfully clone product' do + before do + variant + end + + describe 'response' do + before { send_request } + + it { expect(response).to have_http_status(:found) } + it { expect(response).to be_redirect } + + it 'set flash error' do + expected_error = Spree.t('notice_messages.product_not_cloned', error: 'Validation failed: Sku has already been taken') + expect(flash[:error]).to eq(expected_error) + end + end + end + end + + context 'stock' do + let(:product) { create(:product) } + + it 'restricts stock location based on accessible attributes' do + expect(Spree::StockLocation).to receive(:accessible_by).and_return([]) + spree_get :stock, id: product + end + end +end diff --git a/backend/spec/controllers/spree/admin/promotion_actions_controller_spec.rb b/backend/spec/controllers/spree/admin/promotion_actions_controller_spec.rb new file mode 100644 index 00000000000..282977133a0 --- /dev/null +++ b/backend/spec/controllers/spree/admin/promotion_actions_controller_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Spree::Admin::PromotionActionsController, type: :controller do + stub_authorization! + + let!(:promotion) { create(:promotion) } + + it 'can create a promotion action of a valid type' do + spree_post :create, promotion_id: promotion.id, action_type: 'Spree::Promotion::Actions::CreateAdjustment' + expect(response).to be_redirect + expect(response).to redirect_to spree.edit_admin_promotion_path(promotion) + expect(promotion.actions.count).to eq(1) + end + + it 'can not create a promotion action of an invalid type' do + spree_post :create, promotion_id: promotion.id, action_type: 'Spree::InvalidType' + expect(response).to be_redirect + expect(response).to redirect_to spree.edit_admin_promotion_path(promotion) + expect(promotion.rules.count).to eq(0) + end +end diff --git a/backend/spec/controllers/spree/admin/promotion_rules_controller_spec.rb b/backend/spec/controllers/spree/admin/promotion_rules_controller_spec.rb new file mode 100644 index 00000000000..1985e22d036 --- /dev/null +++ b/backend/spec/controllers/spree/admin/promotion_rules_controller_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Spree::Admin::PromotionRulesController, type: :controller do + stub_authorization! + + let!(:promotion) { create(:promotion) } + + it 'can create a promotion rule of a valid type' do + spree_post :create, promotion_id: promotion.id, promotion_rule: { type: 'Spree::Promotion::Rules::Product' } + expect(response).to be_redirect + expect(response).to redirect_to spree.edit_admin_promotion_path(promotion) + expect(promotion.rules.count).to eq(1) + end + + it 'can not create a promotion rule of an invalid type' do + spree_post :create, promotion_id: promotion.id, promotion_rule: { type: 'Spree::InvalidType' } + expect(response).to be_redirect + expect(response).to redirect_to spree.edit_admin_promotion_path(promotion) + expect(promotion.rules.count).to eq(0) + end +end diff --git a/backend/spec/controllers/spree/admin/promotions_controller_spec.rb b/backend/spec/controllers/spree/admin/promotions_controller_spec.rb new file mode 100644 index 00000000000..273ed2b3203 --- /dev/null +++ b/backend/spec/controllers/spree/admin/promotions_controller_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Spree::Admin::PromotionsController, type: :controller do + stub_authorization! + + let!(:promotion1) { create(:promotion, name: 'name1', code: 'code1', path: 'path1') } + let!(:promotion2) { create(:promotion, name: 'name2', code: 'code2', path: 'path2') } + let!(:category) { create :promotion_category } + + context '#index' do + it 'succeeds' do + spree_get :index + expect(assigns[:promotions]).to match_array [promotion2, promotion1] + end + + it 'assigns promotion categories' do + spree_get :index + expect(assigns[:promotion_categories]).to match_array [category] + end + + context 'search' do + it 'pages results' do + spree_get :index, per_page: '1' + expect(assigns[:promotions]).to eq [promotion2] + end + + it 'filters by name' do + spree_get :index, q: { name_cont: promotion1.name } + expect(assigns[:promotions]).to eq [promotion1] + end + + it 'filters by code' do + spree_get :index, q: { code_cont: promotion1.code } + expect(assigns[:promotions]).to eq [promotion1] + end + + it 'filters by path' do + spree_get :index, q: { path_cont: promotion1.path } + expect(assigns[:promotions]).to eq [promotion1] + end + end + end + + context '#clone' do + context 'cloning valid promotion' do + subject do + spree_post :clone, id: promotion1.id + end + + it 'creates a copy of promotion' do + expect { subject }.to change { Spree::Promotion.count }.by(1) + end + + it 'creates a copy of promotion with changed fields' do + subject + new_promo = Spree::Promotion.last + expect(new_promo.name).to eq 'New name1' + expect(new_promo.code).to eq 'code1_new' + expect(new_promo.path).to eq 'path1_new' + end + end + + context 'cloning invalid promotion' do + subject do + spree_post :clone, id: promotion3.id + end + + let!(:promotion3) { create(:promotion, name: 'Name3', code: 'code3', path: '') } + + before do + create(:promotion, name: 'Name4', code: 'code4', path: '_new') # promotion 4 + end + + it 'doesnt create a copy of promotion' do + expect { subject }.not_to(change { Spree::Promotion.count }) + end + + it 'returns error' do + subject + expected_error = Spree.t('promotion_not_cloned', error: assigns(:new_promo).errors.full_messages.to_sentence) + expect(flash[:error]).to eq(expected_error) + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/refunds_controller_spec.rb b/backend/spec/controllers/spree/admin/refunds_controller_spec.rb new file mode 100644 index 00000000000..b1d50570b53 --- /dev/null +++ b/backend/spec/controllers/spree/admin/refunds_controller_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Spree::Admin::RefundsController do + stub_authorization! + + describe 'POST create' do + context 'a Spree::Core::GatewayError is raised' do + subject do + spree_post :create, + refund: { amount: '50.0', refund_reason_id: '1' }, + order_id: payment.order.to_param, + payment_id: payment.to_param + end + + let(:payment) { create(:payment) } + + before do + def controller.create + raise Spree::Core::GatewayError, 'An error has occurred' + end + end + + it 'sets an error message with the correct text' do + subject + expect(flash[:error]).to eq 'An error has occurred' + end + + it { is_expected.to render_template(:new) } + end + end +end diff --git a/backend/spec/controllers/spree/admin/reimbursements_controller_spec.rb b/backend/spec/controllers/spree/admin/reimbursements_controller_spec.rb new file mode 100644 index 00000000000..a11fa74e777 --- /dev/null +++ b/backend/spec/controllers/spree/admin/reimbursements_controller_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Spree::Admin::ReimbursementsController, type: :controller do + stub_authorization! + + before do + Spree::RefundReason.find_or_create_by!(name: Spree::RefundReason::RETURN_PROCESSING_REASON, mutable: false) + end + + describe '#create' do + subject do + spree_post :create, order_id: order.to_param, build_from_customer_return_id: customer_return.id + end + + let(:customer_return) { create(:customer_return, line_items_count: 1) } + let(:order) { customer_return.order } + let(:return_item) { customer_return.return_items.first } + let(:payment) { order.payments.first } + + it 'creates the reimbursement' do + expect { subject }.to change { order.reimbursements.count }.by(1) + expect(assigns(:reimbursement).return_items.to_a).to eq customer_return.return_items.to_a + end + + it 'redirects to the edit page' do + subject + expect(response).to redirect_to(spree.edit_admin_order_reimbursement_path(order, assigns(:reimbursement))) + end + end + + describe '#perform' do + subject do + spree_post :perform, order_id: order.to_param, id: reimbursement.to_param + end + + let(:reimbursement) { create(:reimbursement) } + let(:customer_return) { reimbursement.customer_return } + let(:order) { reimbursement.order } + let(:return_items) { reimbursement.return_items } + let(:payment) { order.payments.first } + + it 'redirects to customer return page' do + subject + expect(response).to redirect_to spree.admin_order_reimbursement_path(order, reimbursement) + end + + it 'performs the reimbursement' do + expect do + subject + end.to change { payment.refunds.count }.by(1) + expect(payment.refunds.last.amount).to be > 0 + expect(payment.refunds.last.amount).to eq return_items.to_a.sum(&:total) + end + + context 'a Spree::Core::GatewayError is raised' do + before do + def controller.perform + raise Spree::Core::GatewayError, 'An error has occurred' + end + end + + it 'sets an error message with the correct text' do + subject + expect(flash[:error]).to eq 'An error has occurred' + end + + it 'redirects to the edit page' do + subject + redirect_path = spree.edit_admin_order_reimbursement_path(order, assigns(:reimbursement)) + expect(response).to redirect_to(redirect_path) + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/reports_controller_spec.rb b/backend/spec/controllers/spree/admin/reports_controller_spec.rb new file mode 100644 index 00000000000..395f12de4ae --- /dev/null +++ b/backend/spec/controllers/spree/admin/reports_controller_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Spree::Admin::ReportsController, type: :controller do + stub_authorization! + + after do + Spree::Admin::ReportsController.available_reports.delete_if do |key, _value| + key != :sales_total + end + end + + describe 'ReportsController.available_reports' do + it 'contains sales_total' do + expect(Spree::Admin::ReportsController.available_reports.key?(:sales_total)).to be true + end + + it 'has the proper sales total report description' do + expect(Spree::Admin::ReportsController.available_reports[:sales_total][:description]).to eql('Sales Total For All Orders') + end + end + + describe 'ReportsController.add_available_report!' do + context 'when adding the report name' do + it 'contains the report' do + Spree::Admin::ReportsController.add_available_report!(:some_report) + expect(Spree::Admin::ReportsController.available_reports.key?(:some_report)).to be true + end + end + end + + describe 'GET index' do + it 'is ok' do + spree_get :index + expect(response).to be_ok + end + end + + it 'responds to model_class as Spree::AdminReportsController' do + expect(controller.send(:model_class)).to eql(Spree::Admin::ReportsController) + end +end diff --git a/backend/spec/controllers/spree/admin/resource_controller_spec.rb b/backend/spec/controllers/spree/admin/resource_controller_spec.rb new file mode 100644 index 00000000000..9546aa54895 --- /dev/null +++ b/backend/spec/controllers/spree/admin/resource_controller_spec.rb @@ -0,0 +1,151 @@ +require 'spec_helper' + +module Spree + module Admin + class DummyModelsController < Spree::Admin::ResourceController + prepend_view_path('spec/test_views') + + def model_class + Spree::DummyModel + end + end + end +end + +describe Spree::Admin::DummyModelsController, type: :controller do + stub_authorization! + + after(:all) do + Rails.application.reload_routes! + end + + before do + Spree::Core::Engine.routes.draw do + namespace :admin do + resources :dummy_models do + post :update_positions, on: :member + end + end + end + end + + describe '#new' do + subject do + spree_get :new + end + + it 'succeeds' do + subject + expect(response).to be_successful + end + end + + describe '#edit' do + subject do + spree_get :edit, id: dummy_model.to_param + end + + let(:dummy_model) { Spree::DummyModel.create!(name: 'a dummy_model') } + + it 'succeeds' do + subject + expect(response).to be_successful + end + end + + describe '#create' do + subject { spree_post :create, params } + + let(:params) do + { dummy_model: { name: 'a dummy_model' } } + end + + it 'creates the resource' do + expect { subject }.to change { Spree::DummyModel.count }.by(1) + end + + context 'without any parameters' do + let(:params) { {} } + + before do + allow_any_instance_of(Spree::DummyModel).to receive(:name).and_return('some name') + end + + it 'creates the resource' do + expect { subject }.to change { Spree::DummyModel.count }.by(1) + end + end + end + + describe '#update' do + subject { spree_put :update, params } + + let(:dummy_model) { Spree::DummyModel.create!(name: 'a dummy_model') } + + let(:params) do + { + id: dummy_model.to_param, + dummy_model: { name: 'dummy_model renamed' } + } + end + + it 'updates the resource' do + expect { subject }.to change { dummy_model.reload.name }.from('a dummy_model').to('dummy_model renamed') + end + end + + describe '#destroy' do + subject do + spree_delete :destroy, params + end + + let!(:dummy_model) { Spree::DummyModel.create!(name: 'a dummy_model') } + let(:params) { { id: dummy_model.id } } + + it 'destroys the resource' do + expect { subject }.to change { Spree::DummyModel.count }.from(1).to(0) + end + end + + describe '#update_positions' do + subject do + spree_post :update_positions, id: dummy_model_1.to_param, + positions: { dummy_model_1.id => '2', dummy_model_2.id => '1' }, format: 'js' + end + + let(:dummy_model_1) { Spree::DummyModel.create!(name: 'dummy_model 1', position: 1) } + let(:dummy_model_2) { Spree::DummyModel.create!(name: 'dummy_model 2', position: 2) } + + it 'updates the position of dummy_model 1' do + expect { subject }.to change { dummy_model_1.reload.position }.from(1).to(2) + end + + it 'updates the position of dummy_model 2' do + expect { subject }.to change { dummy_model_2.reload.position }.from(2).to(1) + end + + it 'touches updated_at' do + Timecop.scale(3600) do + expect { subject }.to change { dummy_model_1.reload.updated_at } + end + end + end +end + +module Spree + module Submodule + class Post < Spree::Base + end + end + module Admin + module Submodule + class PostsController < Spree::Admin::ResourceController + prepend_view_path('spec/test_views') + + def model_class + Spree::Submodule::Post + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/return_authorizations_controller_spec.rb b/backend/spec/controllers/spree/admin/return_authorizations_controller_spec.rb new file mode 100644 index 00000000000..0010ba5077f --- /dev/null +++ b/backend/spec/controllers/spree/admin/return_authorizations_controller_spec.rb @@ -0,0 +1,217 @@ +require 'spec_helper' + +describe Spree::Admin::ReturnAuthorizationsController, type: :controller do + stub_authorization! + + # Regression test for #1370 #3 + let!(:order) { create(:shipped_order, line_items_count: 3) } + let!(:return_authorization_reason) { create(:return_authorization_reason) } + let(:inventory_unit_1) { order.inventory_units.order('id asc')[0] } + let(:inventory_unit_2) { order.inventory_units.order('id asc')[1] } + let(:inventory_unit_3) { order.inventory_units.order('id asc')[2] } + + describe '#load_return_authorization_reasons' do + let!(:inactive_rma_reason) { create(:return_authorization_reason, active: false) } + + context 'return authorization has an associated inactive reason' do + let!(:other_inactive_rma_reason) { create(:return_authorization_reason, active: false) } + let(:return_authorization) { create(:return_authorization, reason: inactive_rma_reason) } + + it 'loads all the active rma reasons' do + spree_get :edit, id: return_authorization.to_param, order_id: return_authorization.order.to_param + expect(assigns(:reasons)).to include(return_authorization_reason) + expect(assigns(:reasons)).to include(inactive_rma_reason) + expect(assigns(:reasons)).not_to include(other_inactive_rma_reason) + end + end + + context 'return authorization has an associated active reason' do + let(:return_authorization) { create(:return_authorization, reason: return_authorization_reason) } + + it 'loads all the active rma reasons' do + spree_get :edit, id: return_authorization.to_param, order_id: return_authorization.order.to_param + expect(assigns(:reasons)).to eq [return_authorization_reason] + end + end + + context "return authorization doesn't have an associated reason" do + it 'loads all the active rma reasons' do + spree_get :new, order_id: order.to_param + expect(assigns(:reasons)).to eq [return_authorization_reason] + end + end + end + + describe '#load_return_items' do + shared_context 'without existing return items' do + context 'without existing return items' do + it 'has 3 new @form_return_items' do + subject + expect(assigns(:form_return_items).size).to eq 3 + expect(assigns(:form_return_items).select(&:new_record?).size).to eq 3 + end + end + end + + shared_context 'with existing return items' do + context 'with existing return items' do + let!(:return_item_1) { create(:return_item, inventory_unit: inventory_unit_1, return_authorization: return_authorization) } + + it 'has 1 existing return item and 2 new return items' do + subject + expect(assigns(:form_return_items).size).to eq 3 + expect(assigns(:form_return_items).select(&:persisted?)).to eq [return_item_1] + expect(assigns(:form_return_items).select(&:new_record?).size).to eq 2 + end + end + end + + context '#new' do + subject { spree_get :new, order_id: order.to_param } + + include_context 'without existing return items' + end + + context '#edit' do + subject do + spree_get :edit, id: return_authorization.to_param, + order_id: order.to_param + end + + let(:return_authorization) { create(:return_authorization, order: order) } + + include_context 'without existing return items' + include_context 'with existing return items' + end + + context '#create failed' do + subject do + spree_post :create, return_authorization: { return_authorization_reason_id: -1 }, # invalid reason_id + order_id: order.to_param + end + + include_context 'without existing return items' + end + + context '#update failed' do + subject do + spree_put :update, return_authorization: { return_authorization_reason_id: -1 }, # invalid reason_id + id: return_authorization.to_param, + order_id: order.to_param + end + + let(:return_authorization) { create(:return_authorization, order: order) } + + include_context 'without existing return items' + include_context 'with existing return items' + end + end + + describe '#load_reimbursement_types' do + let(:order) { create(:order) } + let!(:inactive_reimbursement_type) { create(:reimbursement_type, active: false) } + let!(:first_active_reimbursement_type) { create(:reimbursement_type) } + let!(:second_active_reimbursement_type) { create(:reimbursement_type) } + + before do + spree_get :new, order_id: order.to_param + end + + it 'loads all the active reimbursement types' do + expect(assigns(:reimbursement_types)).to include(first_active_reimbursement_type) + expect(assigns(:reimbursement_types)).to include(second_active_reimbursement_type) + expect(assigns(:reimbursement_types)).not_to include(inactive_reimbursement_type) + end + end + + context '#create' do + subject { spree_post :create, params } + + let(:stock_location) { create(:stock_location) } + + let(:params) do + { + order_id: order.to_param, + return_authorization: return_authorization_params + } + end + + let(:return_authorization_params) do + { + memo: '', + stock_location_id: stock_location.id, + return_authorization_reason_id: return_authorization_reason.id + } + end + + it 'can create a return authorization' do + subject + expect(response).to redirect_to spree.admin_order_return_authorizations_path(order) + end + end + + context '#update' do + subject { spree_put :update, params } + + let(:return_authorization) { create(:return_authorization, order: order) } + + let(:params) do + { + id: return_authorization.to_param, + order_id: order.to_param, + return_authorization: return_authorization_params + } + end + let(:return_authorization_params) do + { + memo: '', + return_items_attributes: return_items_params + } + end + + context 'adding an item' do + let(:return_items_params) do + { + '0' => { inventory_unit_id: inventory_unit_1.to_param } + } + end + + context 'without existing items' do + it 'creates a new item' do + expect { subject }.to change { Spree::ReturnItem.count }.by(1) + end + end + + context 'with existing completed items' do + let!(:completed_return_item) do + create(:return_item, return_authorization: return_authorization, + inventory_unit: inventory_unit_1, + reception_status: 'received') + end + + it 'does not create new items' do + expect { subject }.not_to change { Spree::ReturnItem.count } + expect(assigns[:return_authorization].errors['return_items.inventory_unit']).to eq ["#{inventory_unit_1.id} has already been taken by return item #{completed_return_item.id}"] + end + end + end + + context 'removing an item' do + let!(:return_item) do + create(:return_item, return_authorization: return_authorization, inventory_unit: inventory_unit_1) + end + + let(:return_items_params) do + { + '0' => { id: return_item.to_param, _destroy: '1' } + } + end + + context 'with existing items' do + it 'removes the item' do + expect { subject }.to change { Spree::ReturnItem.count }.by(-1) + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/return_index_controller_spec.rb b/backend/spec/controllers/spree/admin/return_index_controller_spec.rb new file mode 100644 index 00000000000..14c0760232b --- /dev/null +++ b/backend/spec/controllers/spree/admin/return_index_controller_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +module Spree + module Admin + describe ReturnIndexController, type: :controller do + stub_authorization! + + describe '#return_authorizations' do + subject do + spree_get :return_authorizations + end + + let(:return_authorization) { create(:return_authorization) } + + before { subject } + + it 'loads return authorizations' do + expect(assigns(:collection)).to include(return_authorization) + end + end + + describe '#customer_returns' do + subject do + spree_get :customer_returns + end + + let(:customer_return) { create(:customer_return) } + + before { subject } + + it 'loads customer returns' do + expect(assigns(:collection)).to include(customer_return) + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/return_items_controller_spec.rb b/backend/spec/controllers/spree/admin/return_items_controller_spec.rb new file mode 100644 index 00000000000..e624409e089 --- /dev/null +++ b/backend/spec/controllers/spree/admin/return_items_controller_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Spree::Admin::ReturnItemsController, type: :controller do + stub_authorization! + + describe '#update' do + subject do + spree_put :update, id: return_item.to_param, return_item: { acceptance_status: new_acceptance_status } + end + + let(:customer_return) { create(:customer_return) } + let(:return_item) { customer_return.return_items.first } + let(:old_acceptance_status) { 'accepted' } + let(:new_acceptance_status) { 'rejected' } + + it 'updates the return item' do + expect do + subject + end.to change { return_item.reload.acceptance_status }.from(old_acceptance_status).to(new_acceptance_status) + end + + it 'redirects to the custome return' do + subject + expect(response).to redirect_to spree.edit_admin_order_customer_return_path(customer_return.order, customer_return) + end + end +end diff --git a/backend/spec/controllers/spree/admin/shipping_methods_controller_spec.rb b/backend/spec/controllers/spree/admin/shipping_methods_controller_spec.rb new file mode 100644 index 00000000000..b1a3b5e4e20 --- /dev/null +++ b/backend/spec/controllers/spree/admin/shipping_methods_controller_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Spree::Admin::ShippingMethodsController, type: :controller do + stub_authorization! + + # Regression test for #1240 + it 'does not hard-delete shipping methods' do + shipping_method = stub_model(Spree::ShippingMethod) + allow(Spree::ShippingMethod).to receive_messages find: shipping_method + expect(shipping_method.deleted_at).to be_nil + spree_delete :destroy, id: 1 + expect(shipping_method.reload.deleted_at).not_to be_nil + end +end diff --git a/backend/spec/controllers/spree/admin/stock_items_controller_spec.rb b/backend/spec/controllers/spree/admin/stock_items_controller_spec.rb new file mode 100644 index 00000000000..7dd4d2405b9 --- /dev/null +++ b/backend/spec/controllers/spree/admin/stock_items_controller_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +module Spree + module Admin + describe StockItemsController, type: :controller do + stub_authorization! + + context 'formats' do + let!(:stock_item) { create(:variant).stock_items.first } + + it 'destroy stock item via js' do + expect do + spree_delete :destroy, format: :js, id: stock_item + end.to change(StockItem, :count).by(-1) + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/stock_locations_controller_spec.rb b/backend/spec/controllers/spree/admin/stock_locations_controller_spec.rb new file mode 100644 index 00000000000..148b3af1f8b --- /dev/null +++ b/backend/spec/controllers/spree/admin/stock_locations_controller_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +module Spree + module Admin + describe StockLocationsController, type: :controller do + stub_authorization! + + # Regression for #4272 + context 'with no countries present' do + it 'cannot create a new stock location' do + spree_get :new + expect(flash[:error]).to eq(Spree.t(:stock_locations_need_a_default_country)) + expect(response).to redirect_to(spree.admin_stock_locations_path) + end + end + + context 'with a default country present' do + before do + country = FactoryBot.create(:country) + Spree::Config[:default_country_id] = country.id + end + + it 'can create a new stock location' do + spree_get :new + expect(response).to be_successful + end + end + + context "with a country with the ISO code of 'US' existing" do + before do + FactoryBot.create(:country, iso: 'US') + end + + it 'can create a new stock location' do + spree_get :new + expect(response).to be_successful + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/stock_transfers_controller_spec.rb b/backend/spec/controllers/spree/admin/stock_transfers_controller_spec.rb new file mode 100644 index 00000000000..3270f3036d1 --- /dev/null +++ b/backend/spec/controllers/spree/admin/stock_transfers_controller_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +module Spree + describe Admin::StockTransfersController, type: :controller do + stub_authorization! + + let!(:stock_transfer1) do + StockTransfer.create do |transfer| + transfer.source_location_id = 1 + transfer.destination_location_id = 2 + transfer.reference = 'PO 666' + end + end + + let!(:stock_transfer2) do + StockTransfer.create do |transfer| + transfer.source_location_id = 3 + transfer.destination_location_id = 4 + transfer.reference = 'PO 666' + end + end + + context '#index' do + it 'gets all transfers without search criteria' do + spree_get :index + expect(assigns[:stock_transfers].count).to eq 2 + end + + it 'searches by source location' do + spree_get :index, q: { source_location_id_eq: 1 } + expect(assigns[:stock_transfers].count).to eq 1 + expect(assigns[:stock_transfers]).to include(stock_transfer1) + end + + it 'searches by destination location' do + spree_get :index, q: { destination_location_id_eq: 4 } + expect(assigns[:stock_transfers].count).to eq 1 + expect(assigns[:stock_transfers]).to include(stock_transfer2) + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/tax_categories_controller_spec.rb b/backend/spec/controllers/spree/admin/tax_categories_controller_spec.rb new file mode 100644 index 00000000000..0e13acb3bfe --- /dev/null +++ b/backend/spec/controllers/spree/admin/tax_categories_controller_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +module Spree + module Admin + describe TaxCategoriesController, type: :controller do + stub_authorization! + + describe 'GET #index' do + subject { spree_get :index } + + it 'is successful' do + expect(subject).to be_successful + end + end + + describe 'PUT #update' do + subject { spree_put :update, id: tax_category.id, tax_category: { name: 'Foo', tax_code: 'Bar' } } + + let(:tax_category) { create :tax_category } + + it 'redirects' do + expect(subject).to be_redirect + end + + it 'updates' do + subject + tax_category.reload + expect(tax_category.name).to eq('Foo') + expect(tax_category.tax_code).to eq('Bar') + end + end + end + end +end diff --git a/backend/spec/controllers/spree/admin/users_controller_spec.rb b/backend/spec/controllers/spree/admin/users_controller_spec.rb new file mode 100644 index 00000000000..d6a05ba5414 --- /dev/null +++ b/backend/spec/controllers/spree/admin/users_controller_spec.rb @@ -0,0 +1,183 @@ +require 'spec_helper' +require 'spree/testing_support/bar_ability' + +describe Spree::Admin::UsersController, type: :controller do + let(:user) { create(:user) } + let(:mock_user) { mock_model Spree.user_class } + + before do + allow(controller).to receive_messages spree_current_user: user + user.spree_roles.clear + stub_const('Spree::User', user.class) + end + + context '#show' do + before do + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + it 'redirects to edit' do + spree_get :show, id: user.id + expect(response).to redirect_to spree.edit_admin_user_path(user) + end + end + + context '#authorize_admin' do + before { use_mock_user } + + it 'grant access to users with an admin role' do + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + spree_post :index + expect(response).to render_template :index + end + + it "allows admins to update a user's API key" do + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + expect(mock_user).to receive(:generate_spree_api_key!).and_return(true) + spree_put :generate_api_key, id: mock_user.id + expect(response).to redirect_to(spree.edit_admin_user_path(mock_user)) + end + + it "allows admins to clear a user's API key" do + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + expect(mock_user).to receive(:clear_spree_api_key!).and_return(true) + spree_put :clear_api_key, id: mock_user.id + expect(response).to redirect_to(spree.edit_admin_user_path(mock_user)) + end + + it 'deny access to users without an admin role' do + allow(user).to receive_messages has_spree_role?: false + spree_post :index + expect(response).to redirect_to(spree.forbidden_path) + end + + describe 'deny access to users with an bar role' do + before do + user.spree_roles << Spree::Role.find_or_create_by(name: 'bar') + Spree::Ability.register_ability(BarAbility) + end + + it '#index' do + spree_post :index + expect(response).to redirect_to(spree.forbidden_path) + end + + it '#update' do + spree_post :update, id: '9' + expect(response).to redirect_to(spree.forbidden_path) + end + end + end + + describe '#create' do + before do + use_mock_user + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + it 'can create a shipping_address' do + expect(Spree.user_class).to receive(:new).with(ActionController::Parameters.new( + 'ship_address_attributes' => { 'city' => 'New York' } + ).permit(ship_address_attributes: permitted_address_attributes)) + spree_post :create, user: { ship_address_attributes: { city: 'New York' } } + end + + it 'can create a billing_address' do + expect(Spree.user_class).to receive(:new).with(ActionController::Parameters.new( + 'bill_address_attributes' => { 'city' => 'New York' } + ).permit(bill_address_attributes: permitted_address_attributes)) + spree_post :create, user: { bill_address_attributes: { city: 'New York' } } + end + + it 'redirects to user edit page' do + spree_post :create, user: user.slice(*permitted_user_attributes) + expect(response).to redirect_to(spree.edit_admin_user_path(assigns[:user])) + end + end + + describe '#update' do + before do + use_mock_user + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + it 'allows shipping address attributes through' do + expect(mock_user).to receive(:update_attributes).with(ActionController::Parameters.new( + 'ship_address_attributes' => { 'city' => 'New York' } + ).permit(ship_address_attributes: permitted_address_attributes)) + spree_put :update, id: mock_user.id, user: { ship_address_attributes: { city: 'New York' } } + end + + it 'allows billing address attributes through' do + expect(mock_user).to receive(:update_attributes).with(ActionController::Parameters.new( + 'bill_address_attributes' => { 'city' => 'New York' } + ).permit(bill_address_attributes: permitted_address_attributes)) + spree_put :update, id: mock_user.id, user: { bill_address_attributes: { city: 'New York' } } + end + + it 'allows updating without password resetting' do + expect(mock_user).to receive(:update_attributes).with(hash_not_including(password: '', password_confirmation: '')) + spree_put :update, id: mock_user.id, user: { password: '', password_confirmation: '', email: 'spree@example.com' } + end + + it 'redirects to user edit page' do + expect(mock_user).to receive(:update_attributes).with(hash_not_including(email: '')).and_return(true) + spree_put :update, id: mock_user.id, user: { email: 'spree@example.com' } + expect(response).to redirect_to(spree.edit_admin_user_path(mock_user)) + end + + it 'render edit page when update got errors' do + expect(mock_user).to receive(:update_attributes).with(hash_not_including(email: '')).and_return(false) + spree_put :update, id: mock_user.id, user: { email: 'invalid_email' } + expect(response).to render_template(:edit) + end + end + + describe '#orders' do + let(:order) { create(:order) } + + before do + user.orders << order + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + it 'assigns a list of the users orders' do + spree_get :orders, id: user.id + expect(assigns[:orders].count).to eq 1 + expect(assigns[:orders].first).to eq order + end + + it 'assigns a ransack search for Spree::Order' do + spree_get :orders, id: user.id + expect(assigns[:search]).to be_a Ransack::Search + expect(assigns[:search].klass).to eq Spree::Order + end + end + + describe '#items' do + let(:order) { create(:order) } + + before do + user.orders << order + user.spree_roles << Spree::Role.find_or_create_by(name: 'admin') + end + + it 'assigns a list of the users orders' do + spree_get :items, id: user.id + expect(assigns[:orders].count).to eq 1 + expect(assigns[:orders].first).to eq order + end + + it 'assigns a ransack search for Spree::Order' do + spree_get :items, id: user.id + expect(assigns[:search]).to be_a Ransack::Search + expect(assigns[:search].klass).to eq Spree::Order + end + end +end + +def use_mock_user + allow(mock_user).to receive(:save).and_return(true) + allow(Spree.user_class).to receive(:find).with(mock_user.id.to_s).and_return(mock_user) + allow(Spree.user_class).to receive(:new).and_return(mock_user) +end diff --git a/backend/spec/controllers/spree/admin/variants_controller_spec.rb b/backend/spec/controllers/spree/admin/variants_controller_spec.rb new file mode 100644 index 00000000000..e57a5ce010b --- /dev/null +++ b/backend/spec/controllers/spree/admin/variants_controller_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +module Spree + module Admin + describe VariantsController, type: :controller do + stub_authorization! + + describe '#index' do + let(:product) { create(:product) } + let!(:variant_1) { create(:variant, product: product) } + let!(:variant_2) { create(:variant, product: product) } + + context 'deleted is not requested' do + it 'assigns the variants for a requested product' do + spree_get :index, product_id: product.slug + expect(assigns(:collection)).to include variant_1 + expect(assigns(:collection)).to include variant_2 + end + end + + context 'deleted is requested' do + before { variant_2.destroy } + + it 'assigns only deleted variants for a requested product' do + spree_get :index, product_id: product.slug, deleted: 'on' + expect(assigns(:collection)).not_to include variant_1 + expect(assigns(:collection)).to include variant_2 + end + end + end + + describe '#destroy' do + subject(:send_request) do + spree_delete :destroy, product_id: product, id: variant, format: :js + end + + let(:variant) { mock_model(Spree::Variant) } + let(:variants) { double(ActiveRecord::Relation) } + let(:product) { mock_model(Spree::Product) } + let(:products) { double(ActiveRecord::Relation) } + + before do + allow(Spree::Product).to receive(:friendly).and_return(products) + allow(products).to receive(:find).with(product.id.to_s).and_return(product) + allow(product).to receive_message_chain(:variants, :find).with(variant.id.to_s).and_return(variants) + + allow(Spree::Variant).to receive(:find).with(variant.id.to_s).and_return(variant) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(Spree::Product).to receive(:friendly).and_return(products) } + it { expect(products).to receive(:find).with(product.id.to_s).and_return(product) } + it { expect(product).to receive_message_chain(:variants, :find).with(variant.id.to_s).and_return(variants) } + it { expect(Spree::Variant).to receive(:find).with(variant.id.to_s).and_return(variant) } + end + + shared_examples 'correct response' do + it { expect(assigns(:variant)).to eq(variant) } + it { expect(response).to have_http_status(:ok) } + end + + context 'will successfully destroy variant' do + before { allow(variant).to receive(:destroy).and_return(true) } + + describe 'expects to receive' do + after { send_request } + + it { expect(variant).to receive(:destroy).and_return(true) } + end + + describe 'returns response' do + before { send_request } + + it_behaves_like 'correct response' + it { expect(flash[:success]).to eq(Spree.t('notice_messages.variant_deleted')) } + end + end + + context 'will not successfully destroy product' do + let(:error_msg) { 'Failed to delete' } + + before do + allow(variant).to receive_message_chain(:errors, :full_messages).and_return([error_msg]) + allow(variant).to receive(:destroy).and_return(false) + end + + describe 'expects to receive' do + after { send_request } + + it { expect(variant).to receive_message_chain(:errors, :full_messages).and_return([error_msg]) } + it { expect(variant).to receive(:destroy).and_return(false) } + end + + describe 'returns response' do + before { send_request } + + it_behaves_like 'correct response' + + it { expect(flash[:error]).to eq(Spree.t('notice_messages.variant_not_deleted', error: error_msg)) } + end + end + end + end + end +end diff --git a/backend/spec/features/admin/configuration/countries_spec.rb b/backend/spec/features/admin/configuration/countries_spec.rb new file mode 100644 index 00000000000..8b63732a2b4 --- /dev/null +++ b/backend/spec/features/admin/configuration/countries_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +module Spree + describe 'Countries', type: :feature do + stub_authorization! + + it 'deletes a state', js: true do + visit spree.admin_countries_path + click_link 'New Country' + + fill_in 'Name', with: 'Brazil' + fill_in 'Iso Name', with: 'BRL' + click_button 'Create' + + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + + expect { Country.find(country.id) }.to raise_error(StandardError) + end + end +end diff --git a/backend/spec/features/admin/configuration/general_settings_spec.rb b/backend/spec/features/admin/configuration/general_settings_spec.rb new file mode 100644 index 00000000000..214267a5086 --- /dev/null +++ b/backend/spec/features/admin/configuration/general_settings_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe 'General Settings', type: :feature do + stub_authorization! + + before do + create(:store, name: 'Test Store', url: 'test.example.org', mail_from_address: 'test@example.org') + visit spree.edit_admin_general_settings_path + end + + context 'clearing the cache', js: true do + it 'clears the cache' do + expect(page).not_to have_content(Spree.t(:clear_cache_ok)) + visit spree.edit_admin_general_settings_path + expect(page).not_to have_content(Spree.t(:clear_cache_ok)) + expect(page).to have_content(Spree.t(:clear_cache_warning)) + + page.accept_confirm do + click_button 'Clear Cache' + end + + expect(page).to have_content(Spree.t(:clear_cache_ok)) + end + end +end diff --git a/backend/spec/features/admin/configuration/payment_methods_spec.rb b/backend/spec/features/admin/configuration/payment_methods_spec.rb new file mode 100644 index 00000000000..b34c9044c88 --- /dev/null +++ b/backend/spec/features/admin/configuration/payment_methods_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe 'Payment Methods', type: :feature do + stub_authorization! + + before do + visit spree.admin_payment_methods_path + end + + context 'admin visiting payment methods listing page' do + it 'displays existing payment methods' do + create(:check_payment_method) + visit current_path + + within('table#listing_payment_methods') do + expect(all('th')[1].text).to eq('Name') + expect(all('th')[2].text).to eq('Provider') + expect(all('th')[3].text).to eq('Display') + expect(all('th')[4].text).to eq('Active') + end + + within('table#listing_payment_methods') do + expect(page).to have_content('Spree::PaymentMethod::Check') + end + end + end + + context 'admin creating a new payment method' do + it 'is able to create a new payment method' do + click_link 'admin_new_payment_methods_link' + expect(page).to have_content('New Payment Method') + fill_in 'payment_method_name', with: 'check90' + fill_in 'payment_method_description', with: 'check90 desc' + select 'PaymentMethod::Check', from: 'gtwy-type' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + end + + context 'admin editing a payment method', js: true do + before do + create(:check_payment_method) + visit current_path + + within('table#listing_payment_methods') do + click_icon(:edit) + end + end + + it 'is able to edit an existing payment method' do + fill_in 'payment_method_name', with: 'Payment 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(find_field('payment_method_name').value).to eq('Payment 99') + end + + it 'displays validation errors' do + fill_in 'payment_method_name', with: '' + click_button 'Update' + expect(page).to have_content("Name can't be blank") + end + end +end diff --git a/backend/spec/features/admin/configuration/roles_spec.rb b/backend/spec/features/admin/configuration/roles_spec.rb new file mode 100644 index 00000000000..c90b8b7a0f2 --- /dev/null +++ b/backend/spec/features/admin/configuration/roles_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe 'Roles', type: :feature do + stub_authorization! + + before do + create(:role, name: 'admin') + create(:role, name: 'user') + visit spree.admin_path + click_link 'Configuration' + # Crap workaround for animation to finish expanding so click doesn't hit ReimbursementTypes. + sleep 1 + click_link 'Roles' + end + + context 'show' do + it 'displays existing roles' do + within_row(1) { expect(page).to have_content('admin') } + within_row(2) { expect(page).to have_content('user') } + end + end + + context 'create' do + it 'is able to create a new role' do + click_link 'admin_new_role_link' + expect(page).to have_content('New Role') + fill_in 'role_name', with: 'blogger' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + end + + context 'edit' do + it 'is not able to edit the admin role' do + within_row(1) do + expect(find('td:nth-child(2)')).not_to have_selector(:css, 'span.icon-edit') + expect(find('td:nth-child(2)')).not_to have_selector(:css, 'span.icon-delete') + end + end + it 'is able to edit the user role' do + within_row(2) do + expect(find('td:nth-child(2)')).to have_selector(:css, 'span.icon-edit') + expect(find('td:nth-child(2)')).to have_selector(:css, 'span.icon-delete') + end + end + end +end diff --git a/backend/spec/features/admin/configuration/shipping_methods_spec.rb b/backend/spec/features/admin/configuration/shipping_methods_spec.rb new file mode 100644 index 00000000000..8c21c7cb4e5 --- /dev/null +++ b/backend/spec/features/admin/configuration/shipping_methods_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe 'Shipping Methods', type: :feature do + stub_authorization! + let!(:zone) { create(:global_zone) } + let!(:shipping_method) { create(:shipping_method, zones: [zone]) } + + after do + Capybara.ignore_hidden_elements = true + end + + before do + Capybara.ignore_hidden_elements = false + # HACK: To work around no email prompting on check out + allow_any_instance_of(Spree::Order).to receive_messages(require_email: false) + create(:check_payment_method) + + visit spree.admin_shipping_methods_path + end + + context 'show' do + it 'displays existing shipping methods' do + within_row(1) do + expect(column_text(1)).to eq(shipping_method.name) + expect(column_text(2)).to eq(zone.name) + expect(column_text(3)).to eq('Flat rate') + expect(column_text(4)).to eq('Both') + end + end + end + + context 'create' do + it 'is able to create a new shipping method' do + click_link 'New Shipping Method' + + fill_in 'shipping_method_name', with: 'bullock cart' + + within('#shipping_method_categories_field') do + check first("input[type='checkbox']")['name'] + end + + click_on 'Create' + expect(page).to have_current_path(spree.edit_admin_shipping_method_path(Spree::ShippingMethod.last)) + end + end + + # Regression test for #1331 + context 'update' do + it 'can change the calculator', js: true do + within('#listing_shipping_methods') do + click_icon :edit + end + + expect(find(:css, '.calculator-settings-warning')).not_to be_visible + select2_search('Flexible Rate', from: 'Calculator') + expect(find(:css, '.calculator-settings-warning')).to be_visible + + click_button 'Update' + expect(page).not_to have_content('Shipping method is not found') + end + end +end diff --git a/backend/spec/features/admin/configuration/states_spec.rb b/backend/spec/features/admin/configuration/states_spec.rb new file mode 100755 index 00000000000..1074f41ec23 --- /dev/null +++ b/backend/spec/features/admin/configuration/states_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe 'States', type: :feature do + stub_authorization! + + let!(:country) { create(:country) } + + before do + @hungary = Spree::Country.create!(name: 'Hungary', iso_name: 'Hungary') + end + + def go_to_states_page + visit spree.admin_country_states_path(country) + expect(page).to have_selector('#new_state_link') + page.execute_script('$.fx.off = true') + end + + context 'admin visiting states listing' do + let!(:state) { create(:state, country: country) } + + it 'correctly displays the states' do + visit spree.admin_country_states_path(country) + expect(page).to have_content(state.name) + end + end + + context 'creating and editing states' do + it 'allows an admin to edit existing states', js: true do + go_to_states_page + set_select2_field('country', country.id) + + click_link 'new_state_link' + fill_in 'state_name', with: 'Calgary' + fill_in 'Abbreviation', with: 'CL' + click_button 'Create' + expect(page).to have_content('successfully created!') + expect(page).to have_content('Calgary') + end + + it 'allows an admin to create states for non default countries', js: true do + go_to_states_page + set_select2_field '#country', @hungary.id + # Just so the change event actually gets triggered in this spec + # It is definitely triggered in the "real world" + page.execute_script("$('#country').change();") + + click_link 'new_state_link' + fill_in 'state_name', with: 'Pest megye' + fill_in 'Abbreviation', with: 'PE' + click_button 'Create' + expect(page).to have_content('successfully created!') + expect(page).to have_content('Pest megye') + expect(find('#s2id_country span').text).to eq('Hungary') + end + + it 'shows validation errors', js: true do + go_to_states_page + set_select2_field('country', country.id) + + click_link 'new_state_link' + + fill_in 'state_name', with: '' + fill_in 'Abbreviation', with: '' + click_button 'Create' + expect(page).to have_content("Name can't be blank") + end + end +end diff --git a/backend/spec/features/admin/configuration/stock_locations_spec.rb b/backend/spec/features/admin/configuration/stock_locations_spec.rb new file mode 100644 index 00000000000..5553fc7b6c3 --- /dev/null +++ b/backend/spec/features/admin/configuration/stock_locations_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'Stock Locations', type: :feature do + stub_authorization! + let!(:stock_location) { create(:stock_location) } + + before do + visit spree.admin_stock_locations_path + end + + it 'can create a new stock location' do + click_link 'New Stock Location' + fill_in 'Name', with: 'London' + check 'Active' + click_button 'Create' + + expect(page).to have_content('successfully created') + expect(page).to have_content('London') + end + + it 'can delete an existing stock location', js: true do + visit current_path + + expect(find('#listing_stock_locations')).to have_content(stock_location.name) + spree_accept_alert do + click_icon :delete + # Wait for API request to complete. + wait_for_ajax + end + visit current_path + expect(page).to have_content('No Stock Locations found') + end + + it 'can update an existing stock location', js: true do + visit current_path + + expect(page).to have_content(stock_location.name) + + click_icon :edit + fill_in 'Name', with: 'London' + click_button 'Update' + + expect(page).to have_content('successfully updated') + expect(page).to have_content('London') + end +end diff --git a/backend/spec/features/admin/configuration/store_credit_categories_spec.rb b/backend/spec/features/admin/configuration/store_credit_categories_spec.rb new file mode 100644 index 00000000000..02c0eacaa80 --- /dev/null +++ b/backend/spec/features/admin/configuration/store_credit_categories_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe 'Store Credit Categories', type: :feature, js: true do + stub_authorization! + + before do + visit spree.admin_path + click_link 'Configuration' + end + + context 'admin visiting store credit categories list' do + it 'displays existing store credit categories' do + create(:store_credit_category) + click_link 'Store Credit Categories' + + within_row(1) { expect(page).to have_content('Exchange') } + end + end + + context 'admin creating a new store credit category' do + before do + click_link 'Store Credit Categories' + click_link 'admin_new_store_credit_category_link' + end + + it 'is able to create a new store credit category' do + expect(page).to have_content('New Store Credit Category') + fill_in 'store_credit_category_name', with: 'Return' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + + it 'shows validation errors if there are any' do + click_button 'Create' + expect(page).to have_content("Name can't be blank") + end + end + + context 'admin editing a store credit category' do + it 'is able to update an existing store credit category' do + create(:store_credit_category) + click_link 'Store Credit Categories' + within_row(1) { click_icon :edit } + fill_in 'store_credit_category_name', with: 'Return' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('Return') + end + end +end diff --git a/backend/spec/features/admin/configuration/stores_spec.rb b/backend/spec/features/admin/configuration/stores_spec.rb new file mode 100644 index 00000000000..2c0409f9972 --- /dev/null +++ b/backend/spec/features/admin/configuration/stores_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +describe 'Stores admin', type: :feature do + stub_authorization! + + let!(:store) { create(:store) } + + describe 'visiting the stores page' do + it 'is on the stores page' do + visit spree.admin_stores_path + + store_table = page.find('table') + expect(store_table.all('tr').count).to eq 1 + expect(store_table).to have_content(store.name) + expect(store_table).to have_content(store.url) + end + end + + describe 'creating store' do + it 'creates store and associate it with the user' do + visit spree.admin_stores_path + + click_link 'New Store' + page.fill_in 'store_name', with: 'Spree Example Test' + page.fill_in 'store_url', with: 'test.localhost' + page.fill_in 'store_mail_from_address', with: 'spree@example.com' + page.fill_in 'store_code', with: 'SPR' + click_button 'Create' + + expect(page).to have_current_path spree.admin_stores_path + store_table = page.find('table') + expect(store_table.all('tr').count).to eq 2 + expect(Spree::Store.count).to eq 2 + end + end + + describe 'updating store' do + let(:updated_name) { 'New Store Name' } + + it 'creates store and associate it with the user' do + visit spree.admin_stores_path + + click_link 'Edit' + page.fill_in 'store_name', with: updated_name + click_button 'Update' + + expect(page).to have_current_path spree.admin_stores_path + store_table = page.find('table') + expect(store_table).to have_content(updated_name) + expect(store.reload.name).to eq updated_name + end + end + + describe 'deleting store', js: true do + let!(:second_store) { create(:store) } + + it 'updates store in lifetime stats' do + visit spree.admin_stores_path + + spree_accept_alert do + page.all('.icon-delete')[1].click + wait_for_ajax + end + wait_for_ajax + + expect(Spree::Store.find_by_id(second_store.id)).to be_nil + end + end + + describe 'setting default store' do + let!(:store1) { create(:store, default: false) } + + it 'sets a store as default' do + visit spree.admin_stores_path + click_button 'Set as default' + + expect(store.reload.default).to eq false + expect(store1.reload.default).to eq true + end + end +end diff --git a/backend/spec/features/admin/configuration/tax_categories_spec.rb b/backend/spec/features/admin/configuration/tax_categories_spec.rb new file mode 100644 index 00000000000..6e13a780e54 --- /dev/null +++ b/backend/spec/features/admin/configuration/tax_categories_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'Tax Categories', type: :feature do + stub_authorization! + + before do + visit spree.admin_path + click_link 'Configuration' + end + + context 'admin visiting tax categories list' do + it 'displays the existing tax categories' do + create(:tax_category, name: 'Clothing', tax_code: 'CL001', description: 'For Clothing') + click_link 'Tax Categories' + within('h1') { expect(page).to have_content('Tax Categories') } + within_row(1) do + expect(column_text(1)).to eq('Clothing') + expect(column_text(2)).to eq('CL001') + expect(column_text(3)).to eq('For Clothing') + expect(column_text(4)).to eq('No') + end + end + end + + context 'admin creating new tax category' do + before do + click_link 'Tax Categories' + click_link 'admin_new_tax_categories_link' + end + + it 'is able to create new tax category' do + expect(page).to have_content('New Tax Category') + fill_in 'tax_category_name', with: 'sports goods' + fill_in 'tax_category_description', with: 'sports goods desc' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + + it 'shows validation errors if there are any' do + click_button 'Create' + expect(page).to have_content("Name can't be blank") + end + end + + context 'admin editing a tax category' do + it 'is able to update an existing tax category', js: true do + create(:tax_category) + click_link 'Tax Categories' + within_row(1) { click_icon :edit } + fill_in 'tax_category_description', with: 'desc 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('desc 99') + end + end +end diff --git a/backend/spec/features/admin/configuration/tax_rates_spec.rb b/backend/spec/features/admin/configuration/tax_rates_spec.rb new file mode 100644 index 00000000000..d1a4979e50e --- /dev/null +++ b/backend/spec/features/admin/configuration/tax_rates_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe 'Tax Rates', type: :feature do + stub_authorization! + + before { create(:tax_rate, calculator: stub_model(Spree::Calculator)) } + + # Regression test for #1422 + it 'can create a new tax rate' do + visit spree.admin_path + click_link 'Configuration' + click_link 'Tax Rates' + click_link 'New Tax Rate' + fill_in 'Rate', with: '0.05' + click_button 'Create' + expect(page).to have_content('Tax Rate has been successfully created!') + end +end diff --git a/backend/spec/features/admin/configuration/zones_spec.rb b/backend/spec/features/admin/configuration/zones_spec.rb new file mode 100644 index 00000000000..af85b0d4227 --- /dev/null +++ b/backend/spec/features/admin/configuration/zones_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe 'Zones', type: :feature do + stub_authorization! + + before do + Spree::Zone.delete_all + visit spree.admin_path + click_link 'Configuration' + end + + context 'show' do + it 'displays existing zones' do + create(:zone, name: 'eastern', description: 'zone is eastern') + create(:zone, name: 'western', description: 'cool san fran') + click_link 'Zones' + + within_row(1) { expect(page).to have_content('eastern') } + within_row(2) { expect(page).to have_content('western') } + + click_link 'zones_order_by_description_title' + + within_row(1) { expect(page).to have_content('western') } + within_row(2) { expect(page).to have_content('eastern') } + end + end + + context 'create' do + it 'allows an admin to create a new zone' do + click_link 'Zones' + click_link 'admin_new_zone_link' + expect(page).to have_content('New Zone') + fill_in 'zone_name', with: 'japan' + fill_in 'zone_description', with: 'japanese time zone' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + end +end diff --git a/backend/spec/features/admin/homepage_spec.rb b/backend/spec/features/admin/homepage_spec.rb new file mode 100644 index 00000000000..b0a34eaa497 --- /dev/null +++ b/backend/spec/features/admin/homepage_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe 'Homepage', type: :feature do + context 'as admin user' do + stub_authorization! + + context 'visiting the homepage' do + before do + visit spree.admin_path + end + + it "has header text 'Orders'" do + within('h1') { expect(page).to have_content('Orders') } + end + + it 'has a link to overview' do + within('header') { page.find(:xpath, "//a[@href='/admin']") } + end + + it 'has a link to orders' do + page.find_link('Orders')['/admin/orders'] + end + + it 'has a link to products' do + page.find_link('Products')['/admin/products'] + end + + it 'has a link to reports' do + page.find_link('Reports')['/admin/reports'] + end + + it 'has a link to configuration' do + page.find_link('Configuration')['/admin/configurations'] + end + + it 'has a link to return authorizations' do + within('.sidebar') { page.find_link('Return Authorizations')['/admin/return_authorizations'] } + end + + it 'has a link to customer returns' do + within('.sidebar') { page.find_link('Customer Returns')['/admin/customer_returns'] } + end + + context 'version number' do + it 'is displayed' do + within('.sidebar') { expect(page).to have_content(Spree.version) } + end + + context 'if turned off' do + before { Spree::Config[:admin_show_version] = false } + + it 'is not displayed' do + visit spree.admin_path + within('.sidebar') { expect(page).not_to have_content(Spree.version) } + end + end + end + end + + context 'visiting the products tab' do + before do + visit spree.admin_products_path + end + + it 'has a link to products' do + within('.sidebar') { page.find_link('Products')['/admin/products'] } + end + + it 'has a link to option types' do + within('.sidebar') { page.find_link('Option Types')['/admin/option_types'] } + end + + it 'has a link to properties' do + within('.sidebar') { page.find_link('Properties')['/admin/properties'] } + end + + it 'has a link to prototypes' do + within('.sidebar') { page.find_link('Prototypes')['/admin/prototypes'] } + end + end + end + + context 'as fakedispatch user' do + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(nil) + end + + custom_authorization! do |_user| + can [:admin, :edit, :index, :read], Spree::Order + end + + it 'only displays tabs fakedispatch has access to' do + visit spree.admin_path + expect(page).to have_link('Orders') + expect(page).not_to have_link('Products') + expect(page).not_to have_link('Promotions') + expect(page).not_to have_link('Reports') + expect(page).not_to have_link('Configurations') + end + end +end diff --git a/backend/spec/features/admin/locale_spec.rb b/backend/spec/features/admin/locale_spec.rb new file mode 100644 index 00000000000..f139b5186ca --- /dev/null +++ b/backend/spec/features/admin/locale_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe 'setting locale', type: :feature do + stub_authorization! + + before do + I18n.locale = I18n.default_locale + I18n.backend.store_translations(:fr, + date: { + month_names: [] + }, + spree: { + admin: { + tab: { orders: 'Ordres' } + }, + listing_orders: 'Ordres' + }) + Spree::Backend::Config[:locale] = 'fr' + end + + after do + I18n.locale = I18n.default_locale + Spree::Backend::Config[:locale] = 'en' + end + + it 'is in french' do + visit spree.admin_path + click_link 'Ordres' + expect(page).to have_content('Ordres') + end +end diff --git a/backend/spec/features/admin/orders/adjustments_promotions_spec.rb b/backend/spec/features/admin/orders/adjustments_promotions_spec.rb new file mode 100644 index 00000000000..b7fc922ee94 --- /dev/null +++ b/backend/spec/features/admin/orders/adjustments_promotions_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe 'Adjustments Promotions', type: :feature do + stub_authorization! + + before do + create(:promotion_with_item_adjustment, + name: '$10 off', + path: 'test', + code: '10_off', + starts_at: 1.day.ago, + expires_at: 1.day.from_now, + adjustment_rate: 10) + + order = create(:order_with_totals) + line_item = order.line_items.first + # so we can be sure of a determinate price in our assertions + line_item.update_column(:price, 10) + + visit spree.admin_order_adjustments_path(order) + end + + context 'admin adding a promotion' do + context 'successfully' do + it 'creates a new adjustment', js: true do + fill_in 'coupon_code', with: '10_off' + click_button 'Add Coupon Code' + expect(page).to have_content('$10 off') + expect(page).to have_content('-$10.00') + end + end + + context 'for non-existing promotion' do + it 'shows an error message', js: true do + fill_in 'coupon_code', with: 'does_not_exist' + click_button 'Add Coupon Code' + expect(page).to have_content("doesn't exist.") + end + end + + context 'for already applied promotion' do + it 'shows an error message', js: true do + fill_in 'coupon_code', with: '10_off' + click_button 'Add Coupon Code' + expect(page).to have_content('-$10.00') + + fill_in 'coupon_code', with: '10_off' + click_button 'Add Coupon Code' + expect(page).to have_content('already been applied') + end + end + end +end diff --git a/backend/spec/features/admin/orders/adjustments_spec.rb b/backend/spec/features/admin/orders/adjustments_spec.rb new file mode 100644 index 00000000000..174ceb6d4cb --- /dev/null +++ b/backend/spec/features/admin/orders/adjustments_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +describe 'Adjustments', type: :feature do + stub_authorization! + + let!(:order) { create(:completed_order_with_totals, line_items_count: 5) } + let!(:line_item) do + line_item = order.line_items.first + # so we can be sure of a determinate price in our assertions + line_item.update_column(:price, 10) + line_item + end + + before do + create(:tax_adjustment, + adjustable: line_item, + state: 'closed', + order: order, + label: 'VAT 5%', + amount: 10) + + order.adjustments.create!(order: order, label: 'Rebate', amount: 10) + + # To ensure the order totals are correct + order.update_totals + order.persist_totals + + visit spree.admin_orders_path + within_row(1) { click_on order.number } + click_on 'Adjustments' + end + + after do + order.reload.all_adjustments.each do |adjustment| + expect(adjustment.order_id).to equal(order.id) + end + end + + context 'admin managing adjustments' do + it 'displays the correct values for existing order adjustments' do + within_row(1) do + expect(column_text(2)).to eq('VAT 5%') + expect(column_text(3)).to eq('$10.00') + end + end + + it 'only shows eligible adjustments' do + expect(page).not_to have_content('ineligible') + end + end + + context 'admin creating a new adjustment' do + before do + click_link 'New Adjustment' + end + + context 'successfully' do + it 'creates a new adjustment' do + fill_in 'adjustment_amount', with: '10' + fill_in 'adjustment_label', with: 'rebate' + click_button 'Continue' + expect(page).to have_content('successfully created!') + expect(page).to have_content('Total: $80.00') + end + end + + context 'with validation errors' do + it 'does not create a new adjustment' do + fill_in 'adjustment_amount', with: '' + fill_in 'adjustment_label', with: '' + click_button 'Continue' + expect(page).to have_content("Label can't be blank") + end + end + end + + context 'admin editing an adjustment', js: true do + before do + within_row(2) { click_icon :edit } + end + + context 'successfully' do + it 'updates the adjustment' do + fill_in 'adjustment_amount', with: '99' + fill_in 'adjustment_label', with: 'rebate 99' + click_button 'Continue' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('rebate 99') + within('.adjustments') do + expect(page).to have_content('$99.00') + end + + expect(page).to have_content('Total: $159.00') + end + end + + context 'with validation errors' do + it 'does not update the adjustment' do + fill_in 'adjustment_amount', with: '' + fill_in 'adjustment_label', with: '' + click_button 'Continue' + expect(page).to have_content("Label can't be blank") + end + end + end + + context 'deleting an adjustment' do + it 'updates the total', js: true do + spree_accept_alert do + within_row(2) do + click_icon(:delete) + wait_for_ajax + end + end + + expect(page).to have_content(/Total: ?\$170\.00/) + end + end +end diff --git a/backend/spec/features/admin/orders/cancelling_and_resuming_spec.rb b/backend/spec/features/admin/orders/cancelling_and_resuming_spec.rb new file mode 100644 index 00000000000..4de0ac46fb4 --- /dev/null +++ b/backend/spec/features/admin/orders/cancelling_and_resuming_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe 'Cancelling + Resuming', type: :feature do + stub_authorization! + + let(:user) { double(id: 123, has_spree_role?: true, spree_api_key: 'fake', email: 'spree@example.com') } + let(:order) do + order = create(:order) + order.update_columns(state: 'complete', completed_at: Time.current) + order + end + + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive(:try_spree_current_user).and_return(user) + end + + it 'can cancel an order' do + visit spree.edit_admin_order_path(order.number) + click_button 'Cancel' + within('.additional-info') do + within('.state') do + expect(page).to have_content('canceled') + end + end + end + + context 'with a cancelled order' do + before do + order.update_column(:state, 'canceled') + end + + it 'can resume an order' do + visit spree.edit_admin_order_path(order.number) + click_button 'Resume' + within('.additional-info') do + within('.state') do + expect(page).to have_content('resumed') + end + end + end + end +end diff --git a/backend/spec/features/admin/orders/customer_details_spec.rb b/backend/spec/features/admin/orders/customer_details_spec.rb new file mode 100644 index 00000000000..d03813b0e2c --- /dev/null +++ b/backend/spec/features/admin/orders/customer_details_spec.rb @@ -0,0 +1,157 @@ +require 'spec_helper' + +describe 'Customer Details', type: :feature, js: true do + stub_authorization! + + let!(:country) { create(:country, name: 'United States of America', iso: 'US') } + let!(:state) { create(:state, name: 'Alabama', country: country, abbr: 'AL') } + let!(:order) { create(:order, state: 'complete', completed_at: '2011-02-01 12:36:15') } + let!(:product) { create(:product_in_stock) } + # We need a unique name that will appear for the customer dropdown + let!(:ship_address) { create(:address, country: country, state: state, first_name: 'Rumpelstiltskin') } + let!(:bill_address) { create(:address, country: country, state: state, first_name: 'Rumpelstiltskin') } + + let!(:user) { create(:user, email: 'foobar@example.com', ship_address: ship_address, bill_address: bill_address) } + + before do + create(:shipping_method, display_on: 'front_end') + end + + # Value attribute is dynamically set via JS, so not observable via a CSS/XPath selector + # As the browser might take time to make the values visible in the dom we need to + # "intelligiently" wait for that event o prevent a race. + def expect_form_value(id, value) + node = page.find(id) + wait_for_condition { node.value.eql?(value) } + end + + context 'brand new order' do + before do + allow(Spree.user_class).to receive(:find_by).and_return(user) + visit spree.new_admin_order_path + end + # Regression test for #3335 & #5317 + + it 'associates a user when not using guest checkout' do + select2_search product.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'variant_quantity', with: 1 + click_icon :add + end + wait_for_ajax + click_link 'Customer' + targetted_select2 'foobar@example.com', from: '#s2id_customer_search' + # 5317 - Address prefills using user's default. + expect_form_value('#order_bill_address_attributes_firstname', user.bill_address.firstname) + expect_form_value('#order_bill_address_attributes_lastname', user.bill_address.lastname) + expect_form_value('#order_bill_address_attributes_address1', user.bill_address.address1) + expect_form_value('#order_bill_address_attributes_address2', user.bill_address.address2) + expect_form_value('#order_bill_address_attributes_city', user.bill_address.city) + expect_form_value('#order_bill_address_attributes_zipcode', user.bill_address.zipcode) + expect_form_value('#order_bill_address_attributes_country_id', user.bill_address.country_id.to_s) + expect_form_value('#order_bill_address_attributes_state_id', user.bill_address.state_id.to_s) + expect_form_value('#order_bill_address_attributes_phone', user.bill_address.phone) + click_button 'Update' + expect(Spree::Order.last.user).to eq(user) + end + end + + context 'editing an order' do + before do + configure_spree_preferences do |config| + config.default_country_id = country.id + config.company = true + end + + allow(Spree.user_class).to receive(:find_by).and_return(user) + visit spree.admin_orders_path + within('table#listing_orders') { click_icon(:edit) } + end + + context 'selected country has no state' do + before { create(:country, iso: 'BRA', name: 'Brazil') } + + it 'changes state field to text input' do + click_link 'Customer' + + within('#billing') do + targetted_select2 'Brazil', from: '#s2id_order_bill_address_attributes_country_id' + fill_in 'order_bill_address_attributes_state_name', with: 'Piaui' + end + + click_button 'Update' + expect(find_field('order_bill_address_attributes_state_name').value).to eq('Piaui') + end + end + + it 'is able to update customer details for an existing order' do + order.ship_address = create(:address) + order.save! + + click_link 'Customer' + within('#shipping') { fill_in_address 'ship' } + within('#billing') { fill_in_address 'bill' } + + click_button 'Update' + click_link 'Customer' + + # Regression test for #2950 + #2433 + # This act should transition the state of the order as far as it will go too + within('#order_tab_summary') do + expect(find('.state').text).to eq('complete') + end + end + + it 'shows validation errors' do + click_link 'Customer' + click_button 'Update' + expect(page).to have_content("Shipping address first name can't be blank") + end + + it 'updates order email for an existing order with a user' do + order.update_columns(ship_address_id: ship_address.id, bill_address_id: bill_address.id, state: 'confirm', completed_at: nil) + previous_user = order.user + click_link 'Customer' + fill_in 'order_email', with: 'newemail@example.com' + expect(order.user_id).to eq previous_user.id + expect(order.user.email).to eq previous_user.email + expect { click_button 'Update' }.to change { order.reload.email }.to 'newemail@example.com' + end + + # Regression test for #942 + context 'errors when no shipping methods are available' do + before do + Spree::ShippingMethod.delete_all + end + + specify do + click_link 'Customer' + # Need to fill in valid information so it passes validations + fill_in 'order_ship_address_attributes_firstname', with: 'John 99' + fill_in 'order_ship_address_attributes_lastname', with: 'Doe' + fill_in 'order_ship_address_attributes_lastname', with: 'Company' + fill_in 'order_ship_address_attributes_address1', with: '100 first lane' + fill_in 'order_ship_address_attributes_address2', with: '#101' + fill_in 'order_ship_address_attributes_city', with: 'Bethesda' + fill_in 'order_ship_address_attributes_zipcode', with: '20170' + + page.select('Alabama', from: 'order_ship_address_attributes_state_id') + fill_in 'order_ship_address_attributes_phone', with: '123-456-7890' + expect { click_button 'Update' }.not_to raise_error + end + end + end + + def fill_in_address(kind = 'bill') + fill_in 'First Name', with: 'John 99' + fill_in 'Last Name', with: 'Doe' + fill_in 'Company', with: 'Company' + fill_in 'Street Address', with: '100 first lane' + fill_in "Street Address (cont'd)", with: '#101' + fill_in 'City', with: 'Bethesda' + fill_in 'Zip', with: '20170' + targetted_select2 country.name, from: "#s2id_order_#{kind}_address_attributes_country_id" + targetted_select2 state.name, from: "#s2id_order_#{kind}_address_attributes_state_id" + fill_in 'Phone', with: '123-456-7890' + end +end diff --git a/backend/spec/features/admin/orders/line_items_spec.rb b/backend/spec/features/admin/orders/line_items_spec.rb new file mode 100644 index 00000000000..7ca27a8c43d --- /dev/null +++ b/backend/spec/features/admin/orders/line_items_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +# Tests for #3958's features +describe 'Order Line Items', type: :feature, js: true do + stub_authorization! + + before do + # Removing the delivery step causes the order page to render a different + # partial, called _line_items, which shows line items rather than shipments + allow(Spree::Order).to receive_messages checkout_step_names: [:address, :payment, :confirm, :complete] + end + + let!(:order) do + order = create(:order_with_line_items, line_items_count: 1) + order.shipments.destroy_all + order + end + + it "can edit a line item's quantity" do + visit spree.edit_admin_order_path(order) + within('.line-items') do + within_row(1) do + find('.edit-line-item').click + fill_in 'quantity', with: 10 + find('.save-line-item').click + within '.line-item-qty-show' do + expect(page).to have_content('10') + end + within '.line-item-total' do + expect(page).to have_content('$199.90') + end + end + end + end + + it 'can delete a line item' do + visit spree.edit_admin_order_path(order) + + product_name = find('.line-items tr:nth-child(1) .line-item-name').text + + within('.line-items') do + within_row(1) do + spree_accept_alert do + find('.delete-line-item').click + wait_for_ajax + end + end + end + + expect(page).not_to have_content(product_name) + end +end diff --git a/backend/spec/features/admin/orders/listing_spec.rb b/backend/spec/features/admin/orders/listing_spec.rb new file mode 100644 index 00000000000..011e80ae355 --- /dev/null +++ b/backend/spec/features/admin/orders/listing_spec.rb @@ -0,0 +1,273 @@ +require 'spec_helper' + +describe 'Orders Listing', type: :feature do + stub_authorization! + + let(:order1) do + create :order_with_line_items, + created_at: 1.day.from_now, + completed_at: 1.day.from_now, + considered_risky: true, + number: 'R100' + end + + let(:order2) do + create :order, + created_at: 1.day.ago, + completed_at: 1.day.ago, + number: 'R200' + end + + before do + allow_any_instance_of(Spree::OrderInventory).to receive(:add_to_shipment) + # create the order instances after stubbing the `add_to_shipment` method + order1 + order2 + visit spree.admin_orders_path + end + + describe 'listing orders' do + it 'lists existing orders' do + within_row(1) do + expect(column_text(2)).to eq 'R100' + expect(find('td:nth-child(3)')).to have_css '.label-considered_risky' + expect(column_text(4)).to eq 'cart' + end + + within_row(2) do + expect(column_text(2)).to eq 'R200' + expect(find('td:nth-child(3)')).to have_css '.label-considered_safe' + end + end + + it 'is able to sort the orders listing' do + # default is completed_at desc + within_row(1) { expect(page).to have_content('R100') } + within_row(2) { expect(page).to have_content('R200') } + + click_link 'Completed At' + + # Completed at desc + within_row(1) { expect(page).to have_content('R200') } + within_row(2) { expect(page).to have_content('R100') } + + within('table#listing_orders thead') { click_link 'Number' } + + # number asc + within_row(1) { expect(page).to have_content('R100') } + within_row(2) { expect(page).to have_content('R200') } + end + end + + describe 'searching orders' do + it 'is able to search orders' do + fill_in 'q_number_cont', with: 'R200' + click_on 'Filter Results' + within_row(1) do + expect(page).to have_content('R200') + end + + # Ensure that the other order doesn't show up + within('table#listing_orders') { expect(page).not_to have_content('R100') } + end + + it 'returns both complete and incomplete orders when only complete orders is not checked' do + Spree::Order.create! email: 'incomplete@example.com', completed_at: nil, state: 'cart' + click_on 'Filter' + uncheck 'q_completed_at_not_null' + click_on 'Filter Results' + + expect(page).to have_content('R200') + expect(page).to have_content('incomplete@example.com') + end + + it 'is able to filter risky orders' do + # Check risky and filter + check 'q_considered_risky_eq' + click_on 'Filter Results' + + # Insure checkbox still checked + expect(find('#q_considered_risky_eq')).to be_checked + # Insure we have the risky order, R100 + within_row(1) do + expect(page).to have_content('R100') + end + # Insure the non risky order is not present + expect(page).not_to have_content('R200') + end + + it 'is able to filter on variant_sku' do + click_on 'Filter' + fill_in 'q_line_items_variant_sku_eq', with: order1.line_items.first.variant.sku + click_on 'Filter Results' + + within_row(1) do + expect(page).to have_content(order1.number) + end + + expect(page).not_to have_content(order2.number) + end + + context 'when pagination is really short' do + before do + @old_per_page = Spree::Config[:admin_orders_per_page] + Spree::Config[:admin_orders_per_page] = 1 + end + + after do + Spree::Config[:admin_orders_per_page] = @old_per_page + end + + # Regression test for #4004 + it 'is able to go from page to page for incomplete orders' do + Spree::Order.destroy_all + 2.times { Spree::Order.create! email: 'incomplete@example.com', completed_at: nil, state: 'cart' } + click_on 'Filter' + uncheck 'q_completed_at_not_null' + click_on 'Filter Results' + within('.pagination') do + click_link '2' + end + expect(page).to have_content('incomplete@example.com') + expect(find('#q_completed_at_not_null')).not_to be_checked + end + end + + it 'is able to search orders using only completed at input' do + fill_in 'q_created_at_gt', with: Date.current + click_on 'Filter Results' + + within_row(1) { expect(page).to have_content('R100') } + + # Ensure that the other order doesn't show up + within('table#listing_orders') { expect(page).not_to have_content('R200') } + end + + context 'filter on promotions' do + let!(:promotion) { create(:promotion_with_item_adjustment) } + + before do + order1.promotions << promotion + order1.save + visit spree.admin_orders_path + end + + it 'only shows the orders with the selected promotion' do + select promotion.name, from: 'Promotion' + click_on 'Filter Results' + within_row(1) { expect(page).to have_content('R100') } + within('table#listing_orders') { expect(page).not_to have_content('R200') } + end + end + + it 'is able to apply a ransack filter by clicking a quickfilter icon', js: true do + label_pending = page.find '.label-pending' + parent_td = label_pending.find(:xpath, '..') + + # Click the quick filter Pending for order #R100 + within(parent_td) do + find('.js-add-filter').click + end + + expect(page).to have_content('R100') + expect(page).not_to have_content('R200') + end + + context 'filter on shipment state' do + it 'only shows the orders with the selected shipment state' do + select Spree.t("payment_states.#{order1.shipment_state}"), from: 'Shipment State' + click_on 'Filter Results' + within_row(1) { expect(page).to have_content('R100') } + within('table#listing_orders') { expect(page).not_to have_content('R200') } + end + end + + context 'filter on payment state' do + it 'only shows the orders with the selected payment state' do + select Spree.t("payment_states.#{order1.payment_state}"), from: 'Payment State' + click_on 'Filter Results' + within_row(1) { expect(page).to have_content('R100') } + within('table#listing_orders') { expect(page).not_to have_content('R200') } + end + end + + # regression tests for https://github.com/spree/spree/issues/6888 + context 'per page dropdown', js: true do + before do + select '45', from: 'per_page' + wait_for_ajax + expect(page).to have_select('per_page', selected: '45') + expect(page).to have_selector(:css, 'select.per-page-selected-45') + end + + it 'adds per_page parameter to url' do + expect(current_url).to match(/per_page\=45/) + end + + it 'can be used with search filtering' do + click_on 'Filter' + fill_in 'q_number_cont', with: 'R200' + click_on 'Filter Results' + expect(page).not_to have_content('R100') + within_row(1) { expect(page).to have_content('R200') } + expect(current_url).to match(/per_page\=45/) + expect(page).to have_select('per_page', selected: '45') + select '60', from: 'per_page' + wait_for_ajax + expect(page).to have_select('per_page', selected: '60') + expect(page).to have_selector(:css, 'select.per-page-selected-60') + expect(page).not_to have_content('R100') + within_row(1) { expect(page).to have_content('R200') } + expect(current_url).to match(/per_page\=60/) + end + end + + context 'filtering orders', js: true do + let(:promotion) { create(:promotion_with_item_adjustment) } + + before do + order1.promotions << promotion + order1.save + visit spree.admin_orders_path + end + + it 'renders selected filters' do + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_created_at_gt', with: '2018/01/01' + fill_in 'q_created_at_lt', with: '2018/06/30' + fill_in 'q_number_cont', with: 'R100' + select 'cart', from: 'q_state_eq' + select 'paid', from: 'q_payment_state_eq' + select 'pending', from: 'q_shipment_state_eq' + fill_in 'q_bill_address_firstname_start', with: 'John' + fill_in 'q_bill_address_lastname_start', with: 'Smith' + fill_in 'q_email_cont', with: 'john_smith@example.com' + fill_in 'q_line_items_variant_sku_eq', with: 'BAG-00001' + select 'Promo', from: 'q_promotions_id_in' + select 'Spree Test Store', from: 'q_store_id_in' + select 'spree', from: 'q_channel_eq' + end + + click_on 'Filter Results' + + within('.table-active-filters') do + expect(page).to have_content('Start: 2018/01/01') + expect(page).to have_content('Stop: 2018/06/30') + expect(page).to have_content('Order: R100') + expect(page).to have_content('Status: cart') + expect(page).to have_content('Payment State: paid') + expect(page).to have_content('Shipment State: pending') + expect(page).to have_content('First Name Begins With: John') + expect(page).to have_content('Last Name Begins With: Smith') + expect(page).to have_content('Email: john_smith@example.com') + expect(page).to have_content('SKU: BAG-00001') + expect(page).to have_content('Promotion: Promo') + expect(page).to have_content('Store: Spree Test Store') + expect(page).to have_content('Channel: spree') + end + end + end + end +end diff --git a/backend/spec/features/admin/orders/log_entries_spec.rb b/backend/spec/features/admin/orders/log_entries_spec.rb new file mode 100644 index 00000000000..a5fff01830c --- /dev/null +++ b/backend/spec/features/admin/orders/log_entries_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe 'Log entries', type: :feature do + stub_authorization! + + let!(:payment) { create(:payment) } + + context 'with a successful log entry' do + before do + response = ActiveMerchant::Billing::Response.new( + true, + 'Transaction successful', + transid: 'ABCD1234' + ) + + payment.log_entries.create( + source: payment.source, + details: response.to_yaml + ) + end + + it 'shows a successful attempt' do + visit spree.admin_order_payments_path(payment.order) + find("#payment_#{payment.id} a").click + click_link 'Logs' + within('#listing_log_entries') do + expect(page).to have_content('Transaction successful') + end + end + end + + context 'with a failed log entry' do + before do + response = ActiveMerchant::Billing::Response.new( + false, + 'Transaction failed', + transid: 'ABCD1234' + ) + + payment.log_entries.create( + source: payment.source, + details: response.to_yaml + ) + end + + it 'shows a failed attempt' do + visit spree.admin_order_payments_path(payment.order) + find("#payment_#{payment.id} a").click + click_link 'Logs' + within('#listing_log_entries') do + expect(page).to have_content('Transaction failed') + end + end + end +end diff --git a/backend/spec/features/admin/orders/new_order_spec.rb b/backend/spec/features/admin/orders/new_order_spec.rb new file mode 100644 index 00000000000..a07fa5b225b --- /dev/null +++ b/backend/spec/features/admin/orders/new_order_spec.rb @@ -0,0 +1,189 @@ +require 'spec_helper' + +describe 'New Order', type: :feature do + let!(:product) { create(:product_in_stock) } + let!(:state) { create(:state) } + let!(:user) { create(:user, ship_address: create(:address), bill_address: create(:address)) } + + stub_authorization! + + before do + create(:check_payment_method) + create(:shipping_method) + # create default store + allow(Spree.user_class).to receive(:find_by).and_return(user) + create(:store) + visit spree.new_admin_order_path + end + + it 'does check if you have a billing address before letting you add shipments' do + click_on 'Shipments' + expect(page).to have_content 'Please fill in customer info' + expect(page).to have_current_path(spree.edit_admin_order_customer_path(Spree::Order.last)) + end + + it 'completes new order successfully without using the cart', js: true do + select2_search product.name, from: Spree.t(:name_or_sku) + click_icon :add + wait_for_ajax + click_on 'Customer' + select_customer + + check 'order_use_billing' + fill_in_address + click_on 'Update' + + click_on 'Payments' + click_on 'Update' + + expect(page).to have_current_path(spree.admin_order_payments_path(Spree::Order.last)) + click_icon 'capture' + + click_on 'Shipments' + click_on 'Ship' + wait_for_ajax + + expect(page).to have_content('shipped') + end + + context 'adding new item to the order', js: true do + it 'inventory items show up just fine and are also registered as shipments' do + select2_search product.name, from: Spree.t(:name_or_sku) + + within('table.stock-levels') do + fill_in 'variant_quantity', with: 2 + click_icon :add + end + + within('.line-items') do + expect(page).to have_content(product.name) + end + + click_on 'Customer' + select_customer + + check 'order_use_billing' + fill_in_address + click_on 'Update' + + click_on 'Shipments' + + within('.stock-contents') do + expect(page).to have_content(product.name) + end + end + end + + context "adding new item to the order which isn't available", js: true do + before do + product.update(available_on: nil) + select2_search product.name, from: Spree.t(:name_or_sku) + end + + it 'inventory items is displayed' do + expect(page).to have_content(product.name) + expect(page).to have_css('#stock_details') + end + + context 'on increase in quantity the product should be removed from order', js: true do + before do + spree_accept_alert do + within('table.stock-levels') do + fill_in 'variant_quantity', with: 2 + click_icon :add + wait_for_ajax + + page.driver.browser.switch_to.alert.accept + end + end + end + + it { expect(page).not_to have_css('#stock_details') } + end + end + + # Regression test for #3958 + context 'without a delivery step', js: true do + before do + allow(Spree::Order).to receive_messages checkout_step_names: [:address, :payment, :confirm, :complete] + end + + it 'can still see line items' do + select2_search product.name, from: Spree.t(:name_or_sku) + click_icon :add + within('.line-items') do + within('.line-item-name') do + expect(page).to have_content(product.name) + end + within('.line-item-qty-show') do + expect(page).to have_content('1') + end + within('.line-item-price') do + expect(page).to have_content(product.price) + end + end + end + end + + # Regression test for #3336 + context 'start by customer address' do + it 'completes order fine', js: true do + click_on 'Customer' + select_customer + + check 'order_use_billing' + fill_in_address + click_on 'Update' + + click_on 'Shipments' + select2_search product.name, from: Spree.t(:name_or_sku) + click_icon :add + wait_for_ajax + + click_on 'Payments' + click_on 'Continue' + + within('.additional-info .state') do + expect(page).to have_content('complete') + end + end + end + + # Regression test for #5327 + context 'customer with default credit card', js: true do + before do + allow(Spree.user_class).to receive(:find_by).and_return(user) + create(:credit_card, default: true, user: user) + end + + it 'transitions to delivery not to complete' do + select2_search product.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'variant_quantity', with: 1 + click_icon :add + end + wait_for_ajax + click_link 'Customer' + select_customer + click_button 'Update' + expect(Spree::Order.last.state).to eq 'delivery' + end + end + + def fill_in_address(kind = 'bill') + fill_in 'First Name', with: 'John 99' + fill_in 'Last Name', with: 'Doe' + fill_in 'Street Address', with: '100 first lane' + fill_in "Street Address (cont'd)", with: '#101' + fill_in 'City', with: 'Bethesda' + fill_in 'Zip', with: '20170' + targetted_select2_search state.name, from: "#s2id_order_#{kind}_address_attributes_state_id" + fill_in 'Phone', with: '123-456-7890' + end + + def select_customer + within 'div#select-customer' do + targetted_select2_search user.email, from: '#s2id_customer_search' + end + end +end diff --git a/backend/spec/features/admin/orders/order_details_spec.rb b/backend/spec/features/admin/orders/order_details_spec.rb new file mode 100644 index 00000000000..2236d368b1b --- /dev/null +++ b/backend/spec/features/admin/orders/order_details_spec.rb @@ -0,0 +1,707 @@ +require 'spec_helper' + +describe 'Order Details', type: :feature, js: true do + let!(:stock_location) { create(:stock_location_with_items) } + let!(:product) { create(:product, name: 'spree t-shirt', price: 20.00) } + let!(:store) { create(:store) } + let(:order) { create(:order, state: 'complete', completed_at: '2011-02-01 12:36:15', number: 'R100', store_id: store.id) } + let(:state) { create(:state) } + + before do + create(:shipping_method, name: 'Default') + order.shipments.create!(stock_location_id: stock_location.id) + Spree::Cart::AddItem.call(order: order, variant: product.master, quantity: 2) + end + + context 'as Admin' do + stub_authorization! + + context 'store edit page' do + let!(:new_store) { create(:store) } + + before do + product.master.stock_items.first.update_column(:count_on_hand, 100) + visit spree.store_admin_order_path(order) + end + + it 'displays select with current order store name' do + expect(page).to have_content(store.name) + end + + it 'after selecting a store assings a new store to order' do + select2_search new_store.name, from: 'Store' + find('[name=button]').click + + expect(page).to have_content(new_store.name) + end + end + + context 'cart edit page' do + before do + product.master.stock_items.first.update_column(:count_on_hand, 100) + visit spree.cart_admin_order_path(order) + end + + it 'allows me to edit order details' do + expect(page).to have_content('spree t-shirt') + expect(page).to have_content('$40.00') + + within_row(1) do + click_icon :edit + fill_in 'quantity', with: '1' + end + click_icon :save + + within('#order_total') do + expect(page).to have_content('$20.00') + end + end + + it 'can add an item to a shipment' do + select2_search 'spree t-shirt', from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'variant_quantity', with: 2 + click_icon :add + end + + within('#order_total') do + expect(page).to have_content('$80.00') + end + end + + it 'can remove an item from a shipment' do + expect(page).to have_content('spree t-shirt') + + within_row(1) do + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + end + + # Click "ok" on confirmation dialog + expect(page).not_to have_content('spree t-shirt') + end + + # Regression test for #3862 + it 'can cancel removing an item from a shipment' do + expect(page).to have_content('spree t-shirt') + + within_row(1) do + # Click "cancel" on confirmation dialog + dismiss_alert do + click_icon :delete + end + end + + expect(page).to have_content('spree t-shirt') + end + + it 'can add tracking information' do + visit spree.edit_admin_order_path(order) + + within('.show-tracking') do + click_icon :edit + end + fill_in 'tracking', with: 'FOOBAR' + click_icon :save + + expect(page).not_to have_css('input[name=tracking]') + expect(page).to have_content('Tracking: FOOBAR') + end + + it 'can change the shipping method' do + order = create(:completed_order_with_totals) + visit spree.edit_admin_order_path(order) + within('table.table tr.show-method') do + click_icon :edit + end + select2 'Default', from: 'Shipping Method' + click_icon :save + + expect(page).not_to have_css('#selected_shipping_rate_id') + expect(page).to have_content('Default') + end + + it 'can assign a back-end only shipping method' do + create(:shipping_method, name: 'Backdoor', display_on: 'back_end') + order = create( + :completed_order_with_totals, + shipping_method_filter: Spree::ShippingMethod::DISPLAY_ON_BACK_END + ) + visit spree.edit_admin_order_path(order) + within('table tr.show-method') do + click_icon :edit + end + select2 'Backdoor', from: 'Shipping Method' + click_icon :save + + expect(page).not_to have_css('#selected_shipping_rate_id') + expect(page).to have_content('Backdoor') + end + + it 'will show the variant sku', js: false do + order = create(:completed_order_with_totals) + visit spree.edit_admin_order_path(order) + sku = order.line_items.first.variant.sku + expect(page).to have_content("SKU: #{sku}") + end + + context 'with special_instructions present' do + before do + order.update_column(:special_instructions, 'Very special instructions here') + end + + it 'will show the special_instructions', js: false do + visit spree.edit_admin_order_path(order) + expect(page).to have_content('Very special instructions here') + end + end + + context 'when not tracking inventory' do + let(:tote) { create(:product, name: 'Tote', price: 15.00) } + + context "variant doesn't track inventory" do + before do + tote.master.update_column :track_inventory, false + # make sure there's no stock level for any item + tote.master.stock_items.update_all count_on_hand: 0, backorderable: false + end + + it 'adds variant to order just fine' do + select2_search tote.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'variant_quantity', with: 1 + click_icon :add + end + + wait_for_ajax + + within('.line-items') do + expect(page).to have_content(tote.name) + end + end + end + + context "site doesn't track inventory" do + before do + Spree::Config[:track_inventory_levels] = false + tote.master.update_column(:track_inventory, true) + # make sure there's no stock level for any item + tote.master.stock_items.update_all count_on_hand: 0, backorderable: true + end + + after { Spree::Config[:track_inventory_levels] = true } + + it 'adds variant to order just fine' do + select2_search tote.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'variant_quantity', with: 1 + click_icon :add + end + + wait_for_ajax + + within('.line-items') do + expect(page).to have_content(tote.name) + end + end + end + end + + context 'variant out of stock and not backorderable' do + before do + product.master.stock_items.first.update_column(:backorderable, false) + product.master.stock_items.first.update_column(:count_on_hand, 0) + end + + it 'displays out of stock instead of add button' do + select2_search product.name, from: Spree.t(:name_or_sku) + + within('table.stock-levels') do + expect(page).to have_content(Spree.t(:out_of_stock)) + end + end + end + end + + context 'Shipment edit page' do + let!(:stock_location2) { create(:stock_location_with_items, name: 'Clarksville') } + + before do + product.master.stock_items.first.update_column(:backorderable, true) + product.master.stock_items.first.update_column(:count_on_hand, 100) + product.master.stock_items.last.update_column(:count_on_hand, 100) + end + + context 'splitting to location' do + before { visit spree.edit_admin_order_path(order) } + # can not properly implement until poltergeist supports checking alert text + # see https://github.com/teampoltergeist/poltergeist/pull/516 + + it 'should warn you if you have not selected a location or shipment' + + context 'there is enough stock at the other location' do + it 'allows me to make a split' do + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + click_icon :save + + wait_for_ajax + order.reload + + expect(order.shipments.count).to eq(2) + expect(order.shipments.last.backordered?).to eq(false) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + end + + it 'allows me to make a transfer via splitting off all stock' do + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 2 + click_icon :save + + wait_for_ajax + order.reload + + expect(order.shipments.count).to eq(1) + expect(order.shipments.last.backordered?).to eq(false) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location2.id) + end + + it 'does not allow to split more than in the original shipment' do + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 5 + click_icon :save + + wait_for_ajax + order.reload + + expect(order.shipments.count).to eq(1) + expect(order.shipments.last.backordered?).to eq(false) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location2.id) + end + + it 'does not split anything if the input quantity is garbage' do + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 'ff' + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + end + + it 'does not allow less than or equal to zero qty' do + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 0 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + + fill_in 'item_quantity', with: -1 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + end + + context 'A shipment has shipped' do + it 'does not show or let me back to the cart page, nor show the shipment edit buttons', js: false do + order = create(:order, state: 'payment') + order.shipments.create!(stock_location_id: stock_location.id, state: 'shipped') + + visit spree.cart_admin_order_path(order) + + expect(page).to have_current_path(spree.edit_admin_order_path(order)) + expect(page).not_to have_text 'Cart' + end + end + end + + context 'there is not enough stock at the other location' do + context 'and it cannot backorder' do + it 'does not allow me to split stock' do + product.master.stock_items.last.update_column(:backorderable, false) + product.master.stock_items.last.update_column(:count_on_hand, 0) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 2 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location.id) + end + end + + context 'but it can backorder' do + it 'allows me to split and backorder the stock' do + product.master.stock_items.last.update_column(:count_on_hand, 0) + product.master.stock_items.last.update_column(:backorderable, true) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 2 + click_icon :save + + wait_for_ajax + + order.reload + + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(order.shipments.first.stock_location.id).to eq(stock_location2.id) + end + end + end + + context 'multiple items in cart' do + it 'has no problem splitting if multiple items are in the from shipment' do + Spree::Cart::AddItem.call(order: order, variant: create(:variant), quantity: 2) + expect(order.shipments.count).to eq(1) + expect(order.shipments.first.manifest.count).to eq(2) + + within_row(1) { click_icon 'split' } + targetted_select2 stock_location2.name, from: '#s2id_item_stock_location' + click_icon :save + + wait_for_ajax + order.reload + + expect(order.shipments.count).to eq(2) + expect(order.shipments.last.backordered?).to eq(false) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + end + end + + context 'when not tracking inventory' do + let(:tote) { create(:product, name: 'Tote', price: 15.00) } + + context "variant doesn't track inventory" do + before do + tote.master.update_column :track_inventory, false + # make sure there's no stock level for any item + tote.master.stock_items.update_all count_on_hand: 0, backorderable: false + end + + it 'adds variant to order just fine' do + select2_search tote.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'stock_item_quantity', with: 1 + click_icon :add + end + + wait_for_ajax + + within('[data-hook=admin_order_form_fields]') do + expect(page).to have_content(tote.name) + end + end + end + + context "site doesn't track inventory" do + before do + Spree::Config[:track_inventory_levels] = false + tote.master.update_column(:track_inventory, true) + # make sure there's no stock level for any item + tote.master.stock_items.update_all count_on_hand: 0, backorderable: true + end + + after { Spree::Config[:track_inventory_levels] = true } + + it 'adds variant to order just fine' do + select2_search tote.name, from: Spree.t(:name_or_sku) + within('table.stock-levels') do + fill_in 'stock_item_quantity', with: 1 + click_icon :add + end + + wait_for_ajax + + within('[data-hook=admin_order_form_fields]') do + expect(page).to have_content(tote.name) + end + end + end + end + + context 'variant out of stock and not backorderable' do + before do + product.master.stock_items.first.update_column(:backorderable, false) + product.master.stock_items.first.update_column(:count_on_hand, 0) + end + + it 'displays out of stock instead of add button' do + select2_search product.name, from: Spree.t(:name_or_sku) + + within('table.stock-levels') do + expect(page).to have_content(Spree.t(:out_of_stock)) + end + end + end + end + + context 'splitting to shipment' do + before do + @shipment2 = order.shipments.create(stock_location_id: stock_location2.id) + visit spree.edit_admin_order_path(order) + end + + it 'deletes the old shipment if enough are split off' do + expect(order.shipments.count).to eq(2) + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 2 + click_icon :save + + wait_for_ajax + order.reload + + expect(order.shipments.count).to eq(1) + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + end + + context 'receiving shipment can not backorder' do + before { product.master.stock_items.last.update_column(:backorderable, false) } + + it 'does not allow a split if the receiving shipment qty plus the incoming is greater than the count_on_hand' do + expect(order.shipments.count).to eq(2) + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 1 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 200 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(2) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq(1) + end + + it 'does not allow a shipment to split stock to itself' do + within_row(1) { click_icon 'split' } + targetted_select2 order.shipments.first.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 1 + + spree_accept_alert do + click_icon :save + wait_for_ajax + end + + expect(order.shipments.count).to eq(2) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + end + + it 'splits fine if more than one line_item is in the receiving shipment' do + variant2 = create(:variant) + Spree::Cart::AddItem.call(order: order, variant: variant2, quantity: 2, options: { shipment: @shipment2 }) + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 1 + click_icon :save + + wait_for_ajax + + expect(order.shipments.count).to eq(2) + expect(order.shipments.first.inventory_units_for(product.master).sum(&:quantity)).to eq 1 + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq 1 + expect(order.shipments.first.inventory_units_for(variant2).sum(&:quantity)).to eq 0 + expect(order.shipments.last.inventory_units_for(variant2).sum(&:quantity)).to eq 2 + end + end + + context 'receiving shipment can backorder' do + it 'adds more to the backorder' do + product.master.stock_items.last.update_column(:backorderable, true) + product.master.stock_items.last.update_column(:count_on_hand, 0) + expect(@shipment2.reload.backordered?).to eq(false) + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 1 + click_icon :save + + wait_for_ajax + + expect(@shipment2.reload.backordered?).to eq(true) + + within_row(1) { click_icon 'split' } + targetted_select2 @shipment2.number, from: '#s2id_item_stock_location' + fill_in 'item_quantity', with: 1 + click_icon :save + + wait_for_ajax + + expect(order.shipments.count).to eq(1) + expect(order.shipments.last.inventory_units_for(product.master).sum(&:quantity)).to eq(2) + expect(@shipment2.reload.backordered?).to eq(true) + end + end + end + + context 'display order summary' do + before do + visit spree.cart_admin_order_path(order) + end + + it 'contains elements' do + within('.additional-info') do + expect(page).to have_content('complete') + expect(page).to have_content('spree') + expect(page).to have_content('backorder') + expect(page).to have_content('balance due') + end + end + end + end + end + + context 'with only read permissions' do + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(nil) + end + + custom_authorization! do |_user| + can [:admin, :index, :read, :edit], Spree::Order + end + + it 'does not display forbidden links' do + visit spree.edit_admin_order_path(order) + + expect(page).not_to have_button('cancel') + expect(page).not_to have_button('Resend') + + # Order Tabs + expect(page).not_to have_link('Details') + expect(page).not_to have_link('Customer') + expect(page).not_to have_link('Adjustments') + expect(page).not_to have_link('Payments') + expect(page).not_to have_link('Returns') + + # Order item actions + expect(page).not_to have_css('.delete-item') + expect(page).not_to have_css('.split-item') + expect(page).not_to have_css('.edit-item') + expect(page).not_to have_css('.edit-tracking') + + expect(page).not_to have_css('#add-line-item') + end + end + + context 'as Fakedispatch' do + custom_authorization! do |_user| + # allow dispatch to :admin, :index, and :edit on Spree::Order + can [:admin, :edit, :index, :read], Spree::Order + # allow dispatch to :index, :show, :create and :update shipments on the admin + can [:admin, :manage, :read, :ship], Spree::Shipment + end + + before do + allow(Spree.user_class).to receive(:find_by). + with(hash_including(:spree_api_key)). + and_return(Spree.user_class.new) + end + + it 'does not display order tabs or edit buttons without ability', js: false do + visit spree.edit_admin_order_path(order) + + # Order Form + expect(page).not_to have_css('.edit-item') + # Order Tabs + expect(page).not_to have_link('Details') + expect(page).not_to have_link('Customer') + expect(page).not_to have_link('Adjustments') + expect(page).not_to have_link('Payments') + expect(page).not_to have_link('Returns') + end + + it 'can add tracking information' do + visit spree.edit_admin_order_path(order) + within('table.table tr:nth-child(5)') do + click_icon :edit + end + fill_in 'tracking', with: 'FOOBAR' + click_icon :save + + expect(page).not_to have_css('input[name=tracking]') + expect(page).to have_content('Tracking: FOOBAR') + end + + it 'can change the shipping method' do + order = create(:completed_order_with_totals) + visit spree.edit_admin_order_path(order) + within('table.table tr.show-method') do + click_icon :edit + end + select2 'Default', from: 'Shipping Method' + click_icon :save + + expect(page).not_to have_css('#selected_shipping_rate_id') + expect(page).to have_content('Default') + end + + it 'can ship' do + order = create(:order_ready_to_ship) + order.refresh_shipment_rates + visit spree.edit_admin_order_path(order) + click_on 'Ship' + wait_for_ajax + within '.shipment-state' do + expect(page).to have_content('shipped') + end + end + end +end diff --git a/backend/spec/features/admin/orders/payments_spec.rb b/backend/spec/features/admin/orders/payments_spec.rb new file mode 100644 index 00000000000..ef0febd656b --- /dev/null +++ b/backend/spec/features/admin/orders/payments_spec.rb @@ -0,0 +1,242 @@ +require 'spec_helper' + +describe 'Payments', type: :feature, js: true do + stub_authorization! + + context 'with a pre-existing payment' do + let!(:payment) do + create(:payment, + order: order, + amount: order.outstanding_balance, + payment_method: create(:credit_card_payment_method), + state: state) + end + + let(:order) { create(:completed_order_with_totals, number: 'R100', line_items_count: 5) } + let(:state) { 'checkout' } + + before do + visit spree.edit_admin_order_path(order) + click_link 'Payments' + end + + def refresh_page + visit current_path + end + + # Regression tests for #1453 + context 'with a check payment' do + let(:order) { create(:completed_order_with_totals, number: 'R100') } + + let!(:payment) do + create(:payment, + order: order, + amount: order.outstanding_balance, + payment_method: create(:check_payment_method)) # Check + end + + it 'capturing a check payment from a new order' do + click_icon(:capture) + expect(page).not_to have_content('Cannot perform requested operation') + expect(page).to have_content('Payment Updated') + end + + it 'voids a check payment from a new order' do + click_icon(:void) + expect(page).to have_content('Payment Updated') + end + end + + it 'lists all captures for a payment' do + Spree::ShippingMethod.delete_all + capture_amount = order.outstanding_balance / 2 * 100 + payment.capture!(capture_amount) + + visit spree.admin_order_payment_path(order, payment) + expect(page).to have_content 'Capture events' + within '#capture_events' do + within_row(1) do + expect(page).to have_content(capture_amount / 100) + end + end + end + + it 'lists and create payments for an order' do + within_row(1) do + expect(column_text(3)).to eq('$150.00') + expect(column_text(4)).to eq('Credit Card') + expect(column_text(6)).to eq('checkout') + end + + click_icon :void + expect(find('#payment_status').text).to eq('balance due') + expect(page).to have_content('Payment Updated') + + within_row(1) do + expect(column_text(3)).to eq('$150.00') + expect(column_text(4)).to eq('Credit Card') + expect(column_text(6)).to eq('void') + end + + click_on 'New Payment' + expect(page).to have_content('New Payment') + click_button 'Update' + expect(page).to have_content('successfully created!') + + click_icon(:capture) + wait_for_ajax + expect(find('#payment_status').text).to eq('paid') + + expect(page).not_to have_selector('#new_payment_section') + end + + # Regression test for #1269 + it 'cannot create a payment for an order with no payment methods', js: false do + Spree::PaymentMethod.delete_all + order.payments.delete_all + + click_on 'New Payment' + expect(page).to have_content('You cannot create a payment for an order without any payment methods defined.') + expect(page).to have_content('Please define some payment methods first.') + end + + %w[checkout pending].each do |state| + context "payment is #{state.inspect}" do + let(:state) { state } + + it 'allows the amount to be edited by clicking on the edit button then saving' do + within_row(1) do + click_icon(:edit) + fill_in('amount', with: '$1') + click_icon(:save) + expect(page).to have_selector('td.amount span', text: '$1.00') + expect(payment.reload.amount).to eq(1.00) + end + end + + it 'allows the amount to be edited by clicking on the amount then saving' do + within_row(1) do + find('td.amount span').click + fill_in('amount', with: '$1.01') + click_icon(:save) + expect(page).to have_selector('td.amount span', text: '$1.01') + expect(payment.reload.amount).to eq(1.01) + end + end + + it 'allows the amount change to be cancelled by clicking on the cancel button' do + within_row(1) do + click_icon(:edit) + + # Can't use fill_in here, as under poltergeist that will unfocus (and + # thus submit) the field under poltergeist + find('td.amount input').click + page.execute_script("$('td.amount input').val('$1')") + + click_icon(:cancel) + expect(page).to have_selector('td.amount span', text: '$150.00') + expect(payment.reload.amount).to eq(150.00) + end + end + + it 'displays an error when the amount is invalid' do + within_row(1) do + click_icon(:edit) + fill_in('amount', with: 'invalid') + click_icon(:save) + expect(find('td.amount input').value).to eq('invalid') + expect(payment.reload.amount).to eq(150.00) + end + expect(page).to have_selector('.alert-error', text: 'Invalid resource. Please fix errors and try again.') + end + end + end + + context 'payment is completed', js: false do + let(:state) { 'completed' } + + it 'does not allow the amount to be edited' do + within_row(1) do + expect(page).not_to have_selector('td.amount span') + end + end + end + end + + context 'with no prior payments' do + let(:order) { create(:order_with_line_items, line_items_count: 1) } + let!(:payment_method) { create(:credit_card_payment_method) } + + # Regression tests for #4129 + context 'with a credit card payment method' do + before do + visit spree.admin_order_payments_path(order) + end + + it 'is able to create a new credit card payment with valid information' do + fill_in 'Card Number', with: '4111 1111 1111 1111' + fill_in 'Name', with: 'Test User' + fill_in 'Expiration', with: "09 / #{Time.current.year + 1}" + fill_in 'Card Code', with: '007' + # Regression test for #4277 + sleep(1) + expect(find('.ccType', visible: false).value).to eq('visa') + click_button 'Continue' + expect(page).to have_content('Payment has been successfully created!') + end + + it 'is unable to create a new payment with invalid information' do + click_button 'Continue' + expect(page).to have_content('Payment could not be created.') + expect(page).to have_content("Number can't be blank") + expect(page).to have_content("Name can't be blank") + expect(page).to have_content("Verification Value can't be blank") + expect(page).to have_content('Month is not a number') + expect(page).to have_content('Year is not a number') + end + end + + context 'user existing card' do + let!(:cc) do + create(:credit_card, user_id: order.user_id, payment_method: payment_method, gateway_customer_profile_id: 'BGS-RFRE') + end + + before { visit spree.admin_order_payments_path(order) } + + it 'is able to reuse customer payment source', js: false do + expect(find("#card_#{cc.id}")).to be_checked + click_button 'Continue' + expect(page).to have_content('Payment has been successfully created!') + end + end + + context 'with a check' do + let!(:payment_method) { create(:check_payment_method) } + + before do + visit spree.admin_order_payments_path(order.reload) + end + + it 'can successfully be created and captured' do + click_on 'Continue' + expect(page).to have_content('Payment has been successfully created!') + click_icon(:capture) + expect(page).to have_content('Payment Updated') + end + end + + context 'store_credit payment' do + let!(:payment_method) { create(:store_credit_payment_method) } + let!(:category) { create(:store_credit_category, name: 'Default') } + + before do + create(:store_credit, user: order.user, category: category, amount: 500) + visit spree.new_admin_order_payment_path(order.reload) + choose("payment_payment_method_id_#{payment_method.id}") + click_button 'Continue' + end + + it { expect(page).to have_content('successfully created') } + end + end +end diff --git a/backend/spec/features/admin/orders/risk_analysis_spec.rb b/backend/spec/features/admin/orders/risk_analysis_spec.rb new file mode 100644 index 00000000000..3138c71c188 --- /dev/null +++ b/backend/spec/features/admin/orders/risk_analysis_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe 'Order Risk Analysis', type: :feature do + stub_authorization! + + let!(:order) do + create(:completed_order_with_pending_payment) + end + + def visit_order + visit spree.admin_path + click_link 'Orders' + within_row(1) do + click_link order.number + end + end + + context 'the order is considered risky' do + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive_messages try_spree_current_user: create(:user) + + order.payments.first.update_column(:avs_response, 'X') + order.considered_risky! + visit_order + end + + it "displays 'Risk Analysis' box" do + expect(page).to have_content 'Risk Analysis' + end + + it 'can be approved' do + click_button('Approve') + expect(page).to have_content 'Approver' + expect(page).to have_content 'Approved at' + expect(page).to have_content 'Status: complete' + end + end + + context 'the order is not considered risky' do + before do + visit_order + end + + it "does not display 'Risk Analysis' box" do + expect(page).not_to have_content 'Risk Analysis' + end + end +end diff --git a/backend/spec/features/admin/orders/shipments_spec.rb b/backend/spec/features/admin/orders/shipments_spec.rb new file mode 100644 index 00000000000..8e3b627f8ad --- /dev/null +++ b/backend/spec/features/admin/orders/shipments_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe 'Shipments', type: :feature do + stub_authorization! + + let!(:order) { create(:order_ready_to_ship, number: 'R100', state: 'complete', line_items_count: 5) } + + # Regression test for #4025 + context 'a shipment without a shipping method' do + before do + order.shipments.each do |s| + # Deleting the shipping rates causes there to be no shipping methods + s.shipping_rates.delete_all + end + end + + it 'can still be displayed' do + expect { visit spree.edit_admin_order_path(order) }.not_to raise_error + end + end + + context 'shipping an order', js: true do + before do + visit spree.admin_orders_path + within_row(1) do + click_link 'R100' + end + end + + it 'can ship a completed order' do + click_on 'Ship' + wait_for_ajax + + expect(page).to have_content('shipped package') + expect(order.reload.shipment_state).to eq('shipped') + end + end + + context 'moving variants between shipments', js: true do + before do + create(:stock_location, name: 'LA') + visit spree.admin_orders_path + within_row(1) do + click_link 'R100' + end + end + + it 'can move a variant to a new and to an existing shipment' do + expect(order.shipments.count).to eq(1) + + within_row(1) { click_icon :split } + targetted_select2 'LA', from: '#s2id_item_stock_location' + click_icon :save + wait_for_ajax + expect(page.find("#shipment_#{order.shipments.first.id}")).to be_present + + within_row(2) { click_icon :split } + targetted_select2 "LA(#{order.reload.shipments.last.number})", from: '#s2id_item_stock_location' + click_icon :save + wait_for_ajax + expect(page.find("#shipment_#{order.reload.shipments.last.id}")).to be_present + end + end +end diff --git a/backend/spec/features/admin/orders/state_changes_spec.rb b/backend/spec/features/admin/orders/state_changes_spec.rb new file mode 100644 index 00000000000..9de531ef25a --- /dev/null +++ b/backend/spec/features/admin/orders/state_changes_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe 'Order - State Changes', type: :feature do + stub_authorization! + + let!(:order) { create(:order_with_line_items) } + + context 'for completed order' do + before do + order.next! + visit spree.admin_order_state_changes_path(order) + end + + it 'are viewable' do + within_row(1) do + within('td:nth-child(1)') { expect(page).to have_content('Order') } + within('td:nth-child(2)') { expect(page).to have_content('cart') } + within('td:nth-child(3)') { expect(page).to have_content('address') } + end + end + end +end diff --git a/backend/spec/features/admin/products/edit/images_spec.rb b/backend/spec/features/admin/products/edit/images_spec.rb new file mode 100644 index 00000000000..96b16af8f20 --- /dev/null +++ b/backend/spec/features/admin/products/edit/images_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe 'Product Images', type: :feature, js: true do + stub_authorization! + + let(:file_path) { Rails.root + '../../spec/support/ror_ringer.jpeg' } + + before do + # Ensure attachment style keys are symbolized before running all tests + # Otherwise this would result in this error: + # undefined method `processors' for \"48x48>\ + Spree::Image.styles.symbolize_keys! + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with(:SPREE_USE_PAPERCLIP).and_return(true) + end + + context 'uploading, editing, and deleting an image' do + it 'allows an admin to upload and edit an image for a product' do + Spree::Image.attachment_definitions[:attachment].delete :storage if Rails.application.config.use_paperclip + + create(:product) + + visit spree.admin_products_path + click_icon(:edit) + click_link 'Images' + click_link 'new_image_link' + attach_file('image_attachment', file_path) + click_button 'Create' + expect(page).to have_content('successfully created!') + + click_icon(:edit) + fill_in 'image_alt', with: 'ruby on rails t-shirt' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('ruby on rails t-shirt') + + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + expect(page).not_to have_content('ruby on rails t-shirt') + end + end + + # Regression test for #2228 + it 'sees variant images', js: false do + variant = create(:variant) + create_image(variant, File.open(file_path)) + visit spree.admin_product_images_path(variant.product) + + expect(page).not_to have_content('No Images Found.') + within('table.table') do + expect(page).to have_content(variant.options_text) + + # ensure no duplicate images are displayed + expect(page).to have_css('tbody tr', count: 1) + + # ensure variant header is displayed + within('thead') do + expect(page.body).to have_content('Variant') + end + + # ensure variant header is displayed + within('tbody') do + expect(page).to have_content('Size: S') + end + end + end + + it 'does not see variant column when product has no variants', js: false do + product = create(:product) + create_image(product, File.open(file_path)) + visit spree.admin_product_images_path(product) + + expect(page).not_to have_content('No Images Found.') + within('table.table') do + # ensure no duplicate images are displayed + expect(page).to have_css('tbody tr', count: 1) + + # ensure variant header is not displayed + within('thead') do + expect(page).not_to have_content('Variant') + end + + # ensure correct cell count + expect(page).to have_css('thead th', count: 3) + end + end +end diff --git a/backend/spec/features/admin/products/edit/products_spec.rb b/backend/spec/features/admin/products/edit/products_spec.rb new file mode 100644 index 00000000000..a8a6c073e5b --- /dev/null +++ b/backend/spec/features/admin/products/edit/products_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe 'Product Details', type: :feature, js: true do + stub_authorization! + + context 'editing a product' do + before do + create(:product, name: 'Bún thịt nướng', sku: 'A100', + description: 'lorem ipsum', available_on: '2013-08-14 01:02:03') + + visit spree.admin_products_path + within_row(1) { click_icon :edit } + end + + it 'lists the product details' do + click_link 'Details' + + expect(find('.content-header h1').text.strip).to eq('Products / Bún thịt nướng') + expect(find('input#product_name').value).to eq('Bún thịt nướng') + expect(find('input#product_slug').value).to eq('bun-th-t-n-ng') + expect(find('textarea#product_description').text.strip).to eq('lorem ipsum') + expect(find('input#product_price').value).to eq('19.99') + expect(find('input#product_cost_price').value).to eq('17.00') + expect(find('input#product_available_on').value).to eq('2013/08/14') + expect(find('input#product_sku').value).to eq('A100') + end + + it 'handles slug changes' do + fill_in 'product_slug', with: 'random-slug-value' + click_button 'Update' + expect(page).to have_content('successfully updated!') + end + + it 'handles tag changes' do + targetted_select2_search 'example-tag', from: '#s2id_product_tag_list' + wait_for_ajax + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(find('#s2id_product_tag_list')).to have_content('example-tag') + end + + it 'has a link to preview a product' do + allow(Spree::Core::Engine).to receive(:frontend_available?).and_return(true) + allow_any_instance_of(Spree::BaseHelper).to receive(:product_url).and_return('http://example.com/products/product-slug') + click_link 'Details' + expect(page).to have_css('#admin_preview_product') + expect(page).to have_link Spree.t(:preview_product), href: 'http://example.com/products/product-slug' + end + end +end diff --git a/backend/spec/features/admin/products/edit/taxons_spec.rb b/backend/spec/features/admin/products/edit/taxons_spec.rb new file mode 100644 index 00000000000..4d302667962 --- /dev/null +++ b/backend/spec/features/admin/products/edit/taxons_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'Product Taxons', type: :feature, js: true do + stub_authorization! + + after do + Capybara.ignore_hidden_elements = true + end + + before do + Capybara.ignore_hidden_elements = false + end + + context 'managing taxons' do + def selected_taxons + find('#product_taxon_ids').value.split(',').map(&:to_i).uniq + end + + it 'allows an admin to manage taxons' do + taxon_1 = create(:taxon) + taxon_2 = create(:taxon, name: 'Clothing') + product = create(:product) + product.taxons << taxon_1 + + visit spree.admin_products_path + within_row(1) { click_icon :edit } + + expect(find('.select2-search-choice').text).to eq("#{taxon_1.parent.name} -> #{taxon_1.name}") + expect(selected_taxons).to match_array([taxon_1.id]) + + select2_search 'Clothing', from: 'Taxons' + click_button 'Update' + expect(selected_taxons).to match_array([taxon_1.id, taxon_2.id]) + + # Regression test for #2139 + sleep(1) + expect(first('.select2-search-choice', text: taxon_1.name)).to be_present + expect(first('.select2-search-choice', text: taxon_2.name)).to be_present + end + end +end diff --git a/backend/spec/features/admin/products/edit/variants_spec.rb b/backend/spec/features/admin/products/edit/variants_spec.rb new file mode 100644 index 00000000000..275d750bb2e --- /dev/null +++ b/backend/spec/features/admin/products/edit/variants_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe 'Product Variants', type: :feature, js: true do + stub_authorization! + + before do + create(:product) + visit spree.admin_products_path + end + + context 'editing variant option types' do + it 'allows an admin to create option types for a variant' do + within_row(1) { click_icon :edit } + + within('#sidebar') { click_link 'Variants' } + expect(page).to have_content('To add variants, you must first define') + end + + it 'allows admin to create a variant if there are option types' do + click_link 'Option Types' + click_link 'new_option_type_link' + fill_in 'option_type_name', with: 'shirt colors' + fill_in 'option_type_presentation', with: 'colors' + click_button 'Create' + expect(page).to have_content('successfully created!') + + page.find('#option_type_option_values_attributes_0_name').set('color') + page.find('#option_type_option_values_attributes_0_presentation').set('black') + click_button 'Update' + expect(page).to have_content('successfully updated!') + + visit spree.admin_products_path + within_row(1) { click_icon :edit } + + select2_search 'shirt', from: 'Option Types' + click_button 'Update' + expect(page).to have_content('successfully updated!') + + within('#sidebar') { click_link 'Variants' } + click_link 'New Variant' + + targetted_select2 'black', from: '#s2id_variant_option_value_ids' + fill_in 'variant_sku', with: 'A100' + click_button 'Create' + expect(page).to have_content('successfully created!') + + within('.table') do + expect(page).to have_content('19.99') + expect(page).to have_content('black') + expect(page).to have_content('A100') + end + end + end +end diff --git a/backend/spec/features/admin/products/option_types_spec.rb b/backend/spec/features/admin/products/option_types_spec.rb new file mode 100644 index 00000000000..8b387661b4c --- /dev/null +++ b/backend/spec/features/admin/products/option_types_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe 'Option Types', type: :feature, js: true do + stub_authorization! + + before do + visit spree.admin_path + click_link 'Products' + end + + context 'listing option types' do + it 'lists existing option types' do + create(:option_type, name: 'tshirt-color', presentation: 'Color') + create(:option_type, name: 'tshirt-size', presentation: 'Size') + click_link 'Option Types' + within('table#listing_option_types') do + expect(page).to have_content('Color') + expect(page).to have_content('tshirt-color') + expect(page).to have_content('Size') + expect(page).to have_content('tshirt-size') + end + end + end + + context 'creating a new option type' do + it 'allows an admin to create a new option type' do + click_link 'Option Types' + click_link 'new_option_type_link' + expect(page).to have_content('New Option Type') + fill_in 'option_type_name', with: 'shirt colors' + fill_in 'option_type_presentation', with: 'colors' + click_button 'Create' + expect(page).to have_content('successfully created!') + + page.find('#option_type_option_values_attributes_0_name').set('color') + page.find('#option_type_option_values_attributes_0_presentation').set('black') + + click_button 'Update' + expect(page).to have_content('successfully updated!') + end + end + + context 'editing an existing option type' do + it 'allows an admin to update an existing option type' do + create(:option_type, name: 'tshirt-color', presentation: 'Color') + create(:option_type, name: 'tshirt-size', presentation: 'Size') + click_link 'Option Types' + within('table#listing_option_types') { click_icon :edit } + fill_in 'option_type_name', with: 'foo-size 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('foo-size 99') + end + end + + # Regression test for #3204 + it 'can remove a non-persisted option value from an option type' do + create(:option_type) + click_link 'Option Types' + within('table#listing_option_types') { click_icon :edit } + + wait_for_ajax + page.find('tbody#option_values', visible: true) + + expect(all('tbody#option_values tr').select(&:visible?).count).to eq(1) + + # Add a new option type + click_link 'Add Option Value' + expect(all('tbody#option_values tr').select(&:visible?).count).to eq(2) + + # Remove default option type + within('tbody#option_values') do + click_icon :delete + end + # Check that there was no HTTP request + expect(all('div#progress[style]').count).to eq(0) + # Assert that the field is hidden automatically + expect(all('tbody#option_values tr').select(&:visible?).count).to eq(1) + + # Remove added option type + within('tbody#option_values') do + click_icon :delete + end + # Check that there was no HTTP request + expect(all('div#progress[style]').count).to eq(0) + # Assert that the field is hidden automatically + expect(all('tbody#option_values tr').select(&:visible?).count).to eq(0) + end +end diff --git a/backend/spec/features/admin/products/products_spec.rb b/backend/spec/features/admin/products/products_spec.rb new file mode 100644 index 00000000000..42c588337d2 --- /dev/null +++ b/backend/spec/features/admin/products/products_spec.rb @@ -0,0 +1,480 @@ +require 'spec_helper' + +describe 'Products', type: :feature do + context 'as admin user' do + stub_authorization! + + def build_option_type_with_values(name, values) + ot = FactoryBot.create(:option_type, name: name) + values.each do |val| + ot.option_values.create(name: val.downcase, presentation: val) + end + ot + end + + context 'listing products' do + context 'sorting' do + before do + create(:product, name: 'apache baseball cap', price: 10) + create(:product, name: 'zomg shirt', price: 5) + end + + it 'lists existing products with correct sorting by name' do + visit spree.admin_products_path + # Name ASC + within_row(1) { expect(page).to have_content('apache baseball cap') } + within_row(2) { expect(page).to have_content('zomg shirt') } + + # Name DESC + click_link 'admin_products_listing_name_title' + within_row(1) { expect(page).to have_content('zomg shirt') } + within_row(2) { expect(page).to have_content('apache baseball cap') } + end + + it 'lists existing products with correct sorting by price' do + visit spree.admin_products_path + # Name ASC (default) + within_row(1) { expect(page).to have_content('apache baseball cap') } + within_row(2) { expect(page).to have_content('zomg shirt') } + + # Price DESC + click_link 'admin_products_listing_price_title' + within_row(1) { expect(page).to have_content('zomg shirt') } + within_row(2) { expect(page).to have_content('apache baseball cap') } + end + end + + context 'currency displaying' do + context 'using Russian Rubles' do + before do + Spree::Config[:currency] = 'RUB' + create(:product, name: 'Just a product', price: 19.99) + end + + # Regression test for #2737 + context 'uses руб as the currency symbol' do + it 'on the products listing page' do + visit spree.admin_products_path + within_row(1) { expect(page).to have_content('19.99 ₽') } + end + end + end + end + end + + context 'searching products' do + it 'is able to search deleted products' do + create(:product, name: 'apache baseball cap', deleted_at: '2011-01-06 18:21:13') + create(:product, name: 'zomg shirt') + + visit spree.admin_products_path + expect(page).to have_content('zomg shirt') + expect(page).not_to have_content('apache baseball cap') + + check 'Show Deleted' + click_on 'Search' + + expect(page).to have_content('zomg shirt') + expect(page).to have_content('apache baseball cap') + + uncheck 'Show Deleted' + click_on 'Search' + + expect(page).to have_content('zomg shirt') + expect(page).not_to have_content('apache baseball cap') + end + + it 'is able to search products by their properties' do + create(:product, name: 'apache baseball cap', sku: 'A100') + create(:product, name: 'apache baseball cap2', sku: 'B100') + create(:product, name: 'zomg shirt') + + visit spree.admin_products_path + fill_in 'q_name_cont', with: 'ap' + click_on 'Search' + + expect(page).to have_content('apache baseball cap') + expect(page).to have_content('apache baseball cap2') + expect(page).not_to have_content('zomg shirt') + + fill_in 'q_variants_including_master_sku_cont', with: 'A1' + click_on 'Search' + + expect(page).to have_content('apache baseball cap') + expect(page).not_to have_content('apache baseball cap2') + expect(page).not_to have_content('zomg shirt') + end + end + + context 'creating a new product from a prototype', js: true do + def build_option_type_with_values(name, values) + ot = FactoryBot.create(:option_type, name: name) + values.each do |val| + ot.option_values.create(name: val.downcase, presentation: val) + end + ot + end + + let(:product_attributes) do + # FactoryBot.attributes_for is un-deprecated! + # https://github.com/thoughtbot/factory_bot/issues/274#issuecomment-3592054 + FactoryBot.attributes_for(:simple_product) + end + + let(:prototype) do + size = build_option_type_with_values('size', %w(Small Medium Large)) + FactoryBot.create(:prototype, name: 'Size', option_types: [size]) + end + + let(:option_values_hash) do + hash = {} + prototype.option_types.each do |i| + hash[i.id.to_s] = i.option_value_ids + end + hash + end + + before do + @option_type_prototype = prototype + @property_prototype = create(:prototype, name: 'Random') + @shipping_category = create(:shipping_category) + visit spree.admin_products_path + click_link 'admin_new_product' + within('#new_product') do + expect(page).to have_content('SKU') + end + end + + it 'allows an admin to create a new product and variants from a prototype' do + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_sku', with: 'B100' + fill_in 'product_price', with: '100' + fill_in 'product_available_on', with: '2012/01/24' + # Just so the datepicker gets out of poltergeists way. + page.execute_script("$('#ui-datepicker-div').hide();") + select 'Size', from: 'Prototype' + wait_for_ajax + check 'Large' + select @shipping_category.name, from: 'product_shipping_category_id' + click_button 'Create' + expect(page).to have_content('successfully created!') + expect(Spree::Product.last.variants.length).to eq(1) + end + + it 'does not display variants when prototype does not contain option types' do + select 'Random', from: 'Prototype' + + fill_in 'product_name', with: 'Baseball Cap' + + expect(page).not_to have_content('Variants') + end + + context 'with html5 validations' do + it 'keeps option values selected if validation fails' do + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_sku', with: 'B100' + fill_in 'product_price', with: '100' + select 'Size', from: 'Prototype' + wait_for_ajax + check 'Large' + click_button 'Create' + + message = page.find('#product_shipping_category_id').native.attribute('validationMessage') + expect(message).to eq('Please select an item in the list.') + expect(field_labeled('Size')).to be_checked + expect(field_labeled('Large')).to be_checked + expect(field_labeled('Small')).not_to be_checked + end + end + + context 'without html5 validations' do + it 'keeps option values selected if validation fails' do + disable_html5_validation + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_sku', with: 'B100' + fill_in 'product_price', with: '100' + select 'Size', from: 'Prototype' + wait_for_ajax + check 'Large' + click_button 'Create' + expect(page).to have_content("Shipping Category can't be blank") + expect(field_labeled('Size')).to be_checked + expect(field_labeled('Large')).to be_checked + expect(field_labeled('Small')).not_to be_checked + end + end + end + + context 'creating a new product' do + before do + @shipping_category = create(:shipping_category) + visit spree.admin_products_path + click_link 'admin_new_product' + within('#new_product') do + expect(page).to have_content('SKU') + end + end + + it 'allows an admin to create a new product' do + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_sku', with: 'B100' + fill_in 'product_price', with: '100' + fill_in 'product_available_on', with: '2012/01/24' + select @shipping_category.name, from: 'product_shipping_category_id' + click_button 'Create' + expect(page).to have_content('successfully created!') + click_button 'Update' + expect(page).to have_content('successfully updated!') + end + + it 'shows validation errors' do + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_sku', with: 'B100' + fill_in 'product_price', with: '100' + click_button 'Create' + expect(page).to have_content("Shipping Category can't be blank") + end + + context 'using a locale with a different decimal format ' do + before do + # change English locale's separator and delimiter to match 19,99 format + I18n.backend.store_translations(:en, + number: { + currency: { + format: { + separator: ',', + delimiter: '.' + } + } + }) + end + + after do + # revert changes to English locale + I18n.backend.store_translations(:en, + number: { + currency: { + format: { + separator: '.', + delimiter: ',' + } + } + }) + end + + it 'shows localized price value on validation errors', js: true do + fill_in 'product_price', with: '19,99' + click_button 'Create' + expect(find('input#product_price').value).to eq('19,99') + end + end + + # Regression test for #2097 + it 'can set the count on hand to a null value' do + fill_in 'product_name', with: 'Baseball Cap' + fill_in 'product_price', with: '100' + select @shipping_category.name, from: 'product_shipping_category_id' + click_button 'Create' + expect(page).to have_content('successfully created!') + click_button 'Update' + expect(page).to have_content('successfully updated!') + end + end + + context 'cloning a product', js: true do + it 'allows an admin to clone a product' do + create(:product) + + visit spree.admin_products_path + within_row(1) do + click_icon :clone + end + + expect(page).to have_content('Product has been cloned') + end + + context 'cloning a deleted product' do + it 'allows an admin to clone a deleted product' do + create(:product, name: 'apache baseball cap') + + visit spree.admin_products_path + click_on 'Filter' + check 'Show Deleted' + click_on 'Search' + + expect(page).to have_content('apache baseball cap') + + within_row(1) do + click_icon :clone + end + + expect(page).to have_content('Product has been cloned') + end + end + end + + context 'updating a product' do + let(:product) { create(:product) } + + let(:prototype) do + size = build_option_type_with_values('size', %w(Small Medium Large)) + FactoryBot.create(:prototype, name: 'Size', option_types: [size]) + end + + before do + @option_type_prototype = prototype + @property_prototype = create(:prototype, name: 'Random') + end + + it 'parses correctly available_on' do + visit spree.admin_product_path(product) + fill_in 'product_available_on', with: '2012/12/25' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(Spree::Product.last.available_on.to_s).to eq('2012-12-25 00:00:00 UTC') + end + + it 'adds option_types when selecting a prototype', js: true do + visit spree.admin_product_path(product) + within('#sidebar') do + click_link 'Properties' + end + click_link 'Select From Prototype' + + within("#prototypes tr#row_#{prototype.id}") do + click_link 'Select' + wait_for_ajax + end + + within(:css, 'tr.product_property:first-child') do + expect(first('input[type=text]').value).to eq('baseball_cap_color') + end + end + + context 'using a locale with a different decimal format' do + before do + # change English locale's separator and delimiter to match 19,99 format + I18n.backend.store_translations( + :en, + number: { + currency: { + format: { + separator: ',', + delimiter: '.' + } + }, + format: { + separator: ',', + delimiter: '.' + } + } + ) + end + + after do + # revert changes to English locale + I18n.backend.store_translations( + :en, + number: { + currency: { + format: { + separator: '.', + delimiter: ',' + } + }, + format: { + separator: '.', + delimiter: ',' + } + } + ) + end + + it 'parses correctly decimal values like weight' do + visit spree.admin_product_path(product) + fill_in 'product_weight', with: '1' + click_button 'Update' + weight_prev = find('#product_weight').value + click_button 'Update' + expect(find('#product_weight').value).to eq(weight_prev) + end + end + end + + context 'deleting a product', js: true do + let!(:product) { create(:product) } + + it 'is still viewable' do + visit spree.admin_products_path + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + click_on 'Filter' + # This will show our deleted product + check 'Show Deleted' + click_on 'Search' + click_link product.name + expect(find('#product_price').value.to_f).to eq(product.price.to_f) + end + end + + context 'filtering products', js: true do + it 'renders selected filters' do + visit spree.admin_products_path + + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_name_cont', with: 'Backpack' + fill_in 'q_variants_including_master_sku_cont', with: 'BAG-00001' + end + + click_on 'Search' + + within('.table-active-filters') do + expect(page).to have_content('Name: Backpack') + expect(page).to have_content('SKU: BAG-00001') + end + end + end + end + + context 'with only product permissions' do + before do + allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(nil) + end + + custom_authorization! do |_user| + can [:admin, :update, :index, :read], Spree::Product + end + let!(:product) { create(:product) } + + it 'only displays accessible links on index' do + visit spree.admin_products_path + + expect(page).to have_link('Products') + expect(page).not_to have_link('Option Types') + expect(page).not_to have_link('Properties') + expect(page).not_to have_link('Prototypes') + expect(page).not_to have_link('New Product') + expect(page).not_to have_css('.icon-clone') + expect(page).to have_css('.icon-edit') + expect(page).not_to have_css('.delete-resource') + end + + it 'only displays accessible links on edit' do + visit spree.admin_product_path(product) + + # product tabs should be hidden + expect(page).to have_link('Details') + expect(page).not_to have_link('Images') + expect(page).not_to have_link('Variants') + expect(page).not_to have_link('Properties') + expect(page).not_to have_link('Stock Management') + + # no create permission + expect(page).not_to have_link('New Product') + end + end +end diff --git a/backend/spec/features/admin/products/properties_spec.rb b/backend/spec/features/admin/products/properties_spec.rb new file mode 100644 index 00000000000..e1b0c9c4240 --- /dev/null +++ b/backend/spec/features/admin/products/properties_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe 'Properties', type: :feature, js: true do + stub_authorization! + + before do + visit spree.admin_products_path + end + + context 'Property index' do + before do + create(:property, name: 'shirt size', presentation: 'size') + create(:property, name: 'shirt fit', presentation: 'fit') + visit spree.admin_properties_path + end + + context 'listing product properties' do + it 'lists the existing product properties' do + within_row(1) do + expect(column_text(1)).to eq('shirt size') + expect(column_text(2)).to eq('size') + end + + within_row(2) do + expect(column_text(1)).to eq('shirt fit') + expect(column_text(2)).to eq('fit') + end + end + end + + context 'searching properties' do + it 'lists properties matching search query' do + click_on 'Filter' + fill_in 'q_name_cont', with: 'size' + click_on 'Search' + + expect(page).to have_content('shirt size') + expect(page).not_to have_content('shirt fit') + end + + it 'renders selected filters' do + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_name_cont', with: 'color' + fill_in 'q_presentation_cont', with: 'shade' + end + + click_on 'Search' + + within('.table-active-filters') do + expect(page).to have_content('Name: color') + expect(page).to have_content('Presentation: shade') + end + end + end + end + + context 'creating a property' do + it 'allows an admin to create a new product property' do + visit spree.admin_properties_path + click_link 'new_property_link' + within('.content-header') { expect(page).to have_content('New Property') } + + fill_in 'property_name', with: 'color of band' + fill_in 'property_presentation', with: 'color' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + end + + context 'editing a property' do + before do + create(:property) + visit spree.admin_properties_path + within_row(1) { click_icon :edit } + end + + it 'allows an admin to edit an existing product property' do + fill_in 'property_name', with: 'model 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('model 99') + end + + it 'shows validation errors' do + fill_in 'property_name', with: '' + click_button 'Update' + expect(page).to have_content("Name can't be blank") + end + end + + context 'linking a property to a product' do + before do + create(:product) + visit spree.admin_products_path + click_icon :edit + within('#sidebar') do + click_link 'Properties' + end + end + + # Regression test for #2279 + it 'successfully create and then remove product property' do + fill_in_property + # Sometimes the page doesn't load before the all check is done + # lazily finding the element gives the page 10 seconds + expect(page).to have_css('tbody#product_properties tr:nth-child(2)') + expect(all('tbody#product_properties tr').count).to eq(2) + + delete_product_property + + check_property_row_count(1) + end + + # Regression test for #4466 + it 'successfully remove and create a product property at the same time' do + fill_in_property + + fill_in 'product_product_properties_attributes_1_property_name', with: 'New Property' + fill_in 'product_product_properties_attributes_1_value', with: 'New Value' + + delete_product_property + + # Give fadeOut time to complete + expect(page).not_to have_selector('#product_product_properties_attributes_0_property_name') + expect(page).not_to have_selector('#product_product_properties_attributes_0_value') + + click_button 'Update' + + expect(page).not_to have_content('Product is not found') + + check_property_row_count(2) + end + + def fill_in_property + fill_in 'product_product_properties_attributes_0_property_name', with: 'A Property' + fill_in 'product_product_properties_attributes_0_value', with: 'A Value' + click_button 'Update' + within('#sidebar') do + click_link 'Properties' + end + end + + def delete_product_property + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + end + + def check_property_row_count(expected_row_count) + within('#sidebar') do + click_link 'Properties' + end + expect(page).to have_css('tbody#product_properties') + expect(all('tbody#product_properties tr').count).to eq(expected_row_count) + end + end +end diff --git a/backend/spec/features/admin/products/prototypes_spec.rb b/backend/spec/features/admin/products/prototypes_spec.rb new file mode 100644 index 00000000000..c4dee4f3713 --- /dev/null +++ b/backend/spec/features/admin/products/prototypes_spec.rb @@ -0,0 +1,113 @@ +require 'spec_helper' + +describe 'Prototypes', type: :feature, js: true do + stub_authorization! + + context 'listing prototypes' do + it 'is able to list existing prototypes' do + create(:property, name: 'model', presentation: 'Model') + create(:property, name: 'brand', presentation: 'Brand') + create(:property, name: 'shirt_fabric', presentation: 'Fabric') + create(:property, name: 'shirt_sleeve_length', presentation: 'Sleeve') + create(:property, name: 'mug_type', presentation: 'Type') + create(:property, name: 'bag_type', presentation: 'Type') + create(:property, name: 'manufacturer', presentation: 'Manufacturer') + create(:property, name: 'bag_size', presentation: 'Size') + create(:property, name: 'mug_size', presentation: 'Size') + create(:property, name: 'gender', presentation: 'Gender') + create(:property, name: 'shirt_fit', presentation: 'Fit') + create(:property, name: 'bag_material', presentation: 'Material') + create(:property, name: 'shirt_type', presentation: 'Type') + p = create(:prototype, name: 'Shirt') + %w(brand gender manufacturer model shirt_fabric shirt_fit shirt_sleeve_length shirt_type).each do |prop| + p.properties << Spree::Property.find_by(name: prop) + end + p = create(:prototype, name: 'Mug') + %w(mug_size mug_type).each do |prop| + p.properties << Spree::Property.find_by(name: prop) + end + p = create(:prototype, name: 'Bag') + %w(bag_type bag_material).each do |prop| + p.properties << Spree::Property.find_by(name: prop) + end + + visit spree.admin_path + click_link 'Products' + click_link 'Prototypes' + + within_row(1) { expect(column_text(1)).to eq 'Shirt' } + within_row(2) { expect(column_text(1)).to eq 'Mug' } + within_row(3) { expect(column_text(1)).to eq 'Bag' } + end + end + + context 'creating a prototype' do + it 'allows an admin to create a new product prototype' do + visit spree.admin_path + click_link 'Products' + click_link 'Prototypes' + + click_link 'new_prototype_link' + within('.content-header') do + expect(page).to have_content('New Prototype') + end + fill_in 'prototype_name', with: 'male shirts' + click_button 'Create' + expect(page).to have_content('successfully created!') + + visit spree.admin_prototypes_path + within_row(1) { click_icon :edit } + fill_in 'prototype_name', with: 'Shirt 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('Shirt 99') + end + end + + context 'editing a prototype' do + it 'allows to empty its properties' do + model_property = create(:property, name: 'model', presentation: 'Model') + brand_property = create(:property, name: 'brand', presentation: 'Brand') + + shirt_prototype = create(:prototype, name: 'Shirt', properties: []) + %w(brand model).each do |prop| + shirt_prototype.properties << Spree::Property.find_by(name: prop) + end + + visit spree.admin_path + click_link 'Products' + click_link 'Prototypes' + + click_icon :edit + property_ids = find_field('prototype_property_ids').value.map(&:to_i) + expect(property_ids).to match_array [model_property.id, brand_property.id] + + unselect 'Brand', from: 'prototype_property_ids' + unselect 'Model', from: 'prototype_property_ids' + + click_button 'Update' + + click_icon :edit + + expect(find_field('prototype_property_ids').value).to be_empty + end + end + + it 'is deletable' do + shirt_prototype = create(:prototype, name: 'Shirt', properties: []) + shirt_prototype.taxons << create(:taxon) + + visit spree.admin_path + click_link 'Products' + click_link 'Prototypes' + + spree_accept_alert do + within("#spree_prototype_#{shirt_prototype.id}") do + page.find('.delete-resource').click + end + wait_for_ajax + + expect(page).to have_content("Prototype \"#{shirt_prototype.name}\" has been successfully removed!") + end + end +end diff --git a/backend/spec/features/admin/products/stock_management_spec.rb b/backend/spec/features/admin/products/stock_management_spec.rb new file mode 100644 index 00000000000..f32f7ff8a43 --- /dev/null +++ b/backend/spec/features/admin/products/stock_management_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +describe 'Stock Management', type: :feature, js: true do + stub_authorization! + + context 'given a product with a variant and a stock location' do + let!(:stock_location) { create(:stock_location, name: 'Default') } + let!(:product) { create(:product, name: 'apache baseball cap', price: 10) } + let!(:variant) { product.master } + + before do + stock_location.stock_item(variant).update_column(:count_on_hand, 10) + visit spree.stock_admin_product_path(product) + end + + context "toggle backorderable for a variant's stock item" do + let(:backorderable) { find '.stock_item_backorderable' } + + before do + expect(backorderable).to be_checked + backorderable.set(false) + wait_for_ajax + end + + it 'persists the value when page reload' do + visit current_path + expect(backorderable).not_to be_checked + end + end + + context "toggle track inventory for a variant's stock item" do + let(:track_inventory) { find '.track_inventory_checkbox' } + + before do + expect(track_inventory).to be_checked + track_inventory.set(false) + wait_for_ajax + end + + it 'persists the value when page reloaded' do + visit current_path + expect(track_inventory).not_to be_checked + end + end + + # Regression test for #2896 + # The regression was that unchecking the last checkbox caused a redirect + # to happen. By ensuring that we're still on an /admin/products URL, we + # assert that the redirect is *not* happening. + it 'can toggle backorderable for the second variant stock item' do + new_location = create(:stock_location, name: 'Another Location') + visit current_url + + new_location_backorderable = find "#stock_item_backorderable_#{new_location.id}" + new_location_backorderable.set(false) + wait_for_ajax + + expect(page.current_url).to include('/admin/products') + end + + it 'can create a new stock movement' do + fill_in 'stock_movement_quantity', with: 5 + select2 'default', from: 'Stock Location' + click_button 'Add Stock' + + expect(page).to have_content('successfully created') + + within(:css, '.stock_location_info table') do + expect(column_text(2)).to eq '15' + end + end + + it 'can create a new negative stock movement' do + fill_in 'stock_movement_quantity', with: -5 + select2 'default', from: 'Stock Location' + click_button 'Add Stock' + + expect(page).to have_content('successfully created') + + within(:css, '.stock_location_info table') do + expect(column_text(2)).to eq '5' + end + end + + context 'with multiple variants' do + let!(:variant) { create(:variant, product: product, sku: 'SPREEC') } + + before do + variant.stock_items.first.update_column(:count_on_hand, 30) + visit current_url + end + + it 'can create a new stock movement for the specified variant' do + fill_in 'stock_movement_quantity', with: 10 + select2 'SPREEC', from: 'Variant' + click_button 'Add Stock' + + expect(page).to have_content('successfully created') + + within('#listing_product_stock tr', text: 'SPREEC') do + within('table') do + expect(column_text(2)).to eq '40' + end + end + end + end + + # Regression test for #3304 + context 'with no stock location' do + let(:product) { create(:product, name: 'apache baseball cap', price: 10) } + let(:variant) { create(:variant, product: product, sku: 'FOOBAR') } + + before do + Spree::StockLocation.delete_all + + visit spree.stock_admin_product_path(product) + end + + it 'redirects to stock locations page', js: false do + expect(page).to have_content(Spree.t(:stock_management_requires_a_stock_location)) + expect(page.current_url).to include('admin/stock_locations') + end + end + end +end diff --git a/backend/spec/features/admin/products/taxonomies_spec.rb b/backend/spec/features/admin/products/taxonomies_spec.rb new file mode 100644 index 00000000000..5f0b91ad189 --- /dev/null +++ b/backend/spec/features/admin/products/taxonomies_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe 'Taxonomies', type: :feature, js: true do + stub_authorization! + + before do + visit spree.admin_path + click_link 'Products' + end + + context 'show' do + it 'displays existing taxonomies' do + create(:taxonomy, name: 'Brand') + create(:taxonomy, name: 'Categories') + visit spree.admin_taxonomies_path + within_row(1) { expect(page).to have_content('Brand') } + within_row(2) { expect(page).to have_content('Categories') } + end + end + + context 'create' do + before do + click_link 'Taxonomies' + click_link 'admin_new_taxonomy_link' + end + + it 'allows an admin to create a new taxonomy' do + expect(page).to have_content('New Taxonomy') + fill_in 'taxonomy_name', with: 'sports' + click_button 'Create' + expect(page).to have_content('successfully created!') + end + + it 'displays validation errors' do + fill_in 'taxonomy_name', with: '' + click_button 'Create' + expect(page).to have_content("can't be blank") + end + end + + context 'edit' do + it 'allows an admin to update an existing taxonomy' do + create(:taxonomy) + click_link 'Taxonomies' + within_row(1) { click_icon :edit } + fill_in 'taxonomy_name', with: 'sports 99' + click_button 'Update' + expect(page).to have_content('successfully updated!') + expect(page).to have_content('sports 99') + end + end +end diff --git a/backend/spec/features/admin/products/variant_spec.rb b/backend/spec/features/admin/products/variant_spec.rb new file mode 100644 index 00000000000..cac5e4ff996 --- /dev/null +++ b/backend/spec/features/admin/products/variant_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'Variants', type: :feature do + stub_authorization! + + let(:product) { create(:product_with_option_types, price: '1.99', cost_price: '1.00', weight: '2.5', height: '3.0', width: '1.0', depth: '1.5') } + + context 'creating a new variant' do + it 'allows an admin to create a new variant', js: true do + product.options.each do |option| + create(:option_value, option_type: option.option_type) + end + + visit spree.admin_products_path + within_row(1) { click_icon :edit } + click_link 'Variants' + click_on 'Add One' + expect(find('input#variant_price').value).to eq('1.99') + expect(find('input#variant_cost_price').value).to eq('1.00') + expect(find('input#variant_weight').value).to eq('2.50') + expect(find('input#variant_height').value).to eq('3.00') + expect(find('input#variant_width').value).to eq('1.00') + expect(find('input#variant_depth').value).to eq('1.50') + expect(page).to have_select('variant[tax_category_id]') + end + end + + context 'listing variants' do + context 'currency displaying' do + context 'using Russian Rubles' do + before do + Spree::Config[:currency] = 'RUB' + create(:variant, product: product, price: 19.99) + end + + # Regression test for #2737 + context 'uses руб as the currency symbol' do + it 'on the products listing page' do + visit spree.admin_product_variants_path(product) + within_row(1) { expect(page).to have_content('19.99 ₽') } + end + end + end + end + end +end diff --git a/backend/spec/features/admin/promotions/adjustments_spec.rb b/backend/spec/features/admin/promotions/adjustments_spec.rb new file mode 100644 index 00000000000..d1807b871d1 --- /dev/null +++ b/backend/spec/features/admin/promotions/adjustments_spec.rb @@ -0,0 +1,284 @@ +require 'spec_helper' + +describe 'Promotion Adjustments', type: :feature, js: true do + stub_authorization! + + context 'coupon promotions' do + before do + visit spree.admin_promotions_path + click_on 'New Promotion' + end + + it 'allows an admin to create a flat rate discount coupon promo' do + fill_in 'Name', with: 'Promotion' + fill_in 'Code', with: 'order' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + expect(page).to have_content(promotion.name) + + select2 'Item total', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + + eventually_fill_in "promotion_promotion_rules_attributes_#{Spree::Promotion.count}_preferred_amount_min", with: 30 + eventually_fill_in "promotion_promotion_rules_attributes_#{Spree::Promotion.count}_preferred_amount_max", with: 60 + within('#rule_fields') { click_button 'Update' } + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Flat Rate', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + + within('.calculator-fields') { fill_in 'Amount', with: 5 } + within('#actions_container') { click_button 'Update' } + + expect(promotion.code).to eq('order') + + first_rule = promotion.rules.first + expect(first_rule.class).to eq(Spree::Promotion::Rules::ItemTotal) + expect(first_rule.preferred_amount_min).to eq(30) + expect(first_rule.preferred_amount_max).to eq(60) + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateAdjustment) + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq(Spree::Calculator::FlatRate) + expect(first_action_calculator.preferred_amount).to eq(5) + end + + it 'allows an admin to create a single user coupon promo with flat rate discount' do + fill_in 'Name', with: 'Promotion' + fill_in 'Usage Limit', with: '1' + fill_in 'Code', with: 'single_use' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + expect(page).to have_content(promotion.name) + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Flat Rate', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('#action_fields') { fill_in 'Amount', with: '5' } + within('#actions_container') { click_button 'Update' } + + expect(promotion.usage_limit).to eq(1) + expect(promotion.code).to eq('single_use') + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateAdjustment) + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq(Spree::Calculator::FlatRate) + expect(first_action_calculator.preferred_amount).to eq(5) + end + + it 'allows an admin to create an automatic promo with flat percent discount' do + fill_in 'Name', with: 'Promotion' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + + expect(page).to have_content(promotion.name) + + select2 'Item total', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_min', with: 30 + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_max', with: 60 + within('#rule_fields') { click_button 'Update' } + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Flat Percent', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('.calculator-fields') { fill_in 'Flat Percent', with: '10' } + within('#actions_container') { click_button 'Update' } + + expect(promotion.code).to be_blank + + first_rule = promotion.rules.first + expect(first_rule.class).to eq(Spree::Promotion::Rules::ItemTotal) + expect(first_rule.preferred_amount_min).to eq(30) + expect(first_rule.preferred_amount_max).to eq(60) + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateAdjustment) + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq(Spree::Calculator::FlatPercentItemTotal) + expect(first_action_calculator.preferred_flat_percent).to eq(10) + end + + it 'allows an admin to create an product promo with percent per item discount' do + create(:product, name: 'RoR Mug') + + fill_in 'Name', with: 'Promotion' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + + expect(page).to have_content(promotion.name) + + select2 'Product(s)', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + select2_search 'RoR Mug', from: 'Choose products' + within('#rule_fields') { click_button 'Update' } + + select2 'Create per-line-item adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Percent Per Item', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('.calculator-fields') { fill_in 'Percent', with: '10' } + within('#actions_container') { click_button 'Update' } + + expect(promotion.code).to be_blank + + first_rule = promotion.rules.first + expect(first_rule.class).to eq(Spree::Promotion::Rules::Product) + expect(first_rule.products.map(&:name)).to include('RoR Mug') + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateItemAdjustments) + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq(Spree::Calculator::PercentOnLineItem) + expect(first_action_calculator.preferred_percent).to eq(10) + end + + it 'allows an admin to create an automatic promotion with free shipping (no code)' do + fill_in 'Name', with: 'Promotion' + click_button 'Create' + + promotion = Spree::Promotion.find_by(name: 'Promotion') + expect(page).to have_content(promotion.name) + + select2 'Item total', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_min', with: '50' + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_max', with: '150' + within('#rule_fields') { click_button 'Update' } + + select2 'Free shipping', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + expect(page).to have_content('Makes all shipments for the order free') + + expect(promotion.code).to be_blank + + first_rule = promotion.rules.first + expect(first_rule.class).to eq(Spree::Promotion::Rules::ItemTotal) + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::FreeShipping) + end + + it 'allows an admin to create an automatic promo requiring a landing page to be visited' do + fill_in 'Name', with: 'Promotion' + fill_in 'Path', with: 'content/cvv' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + + expect(page).to have_content(promotion.name) + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Flat Rate', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('.calculator-fields') { fill_in 'Amount', with: '4' } + within('#actions_container') { click_button 'Update' } + + expect(promotion.path).to eq('content/cvv') + expect(promotion.code).to be_blank + expect(promotion.rules).to be_blank + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateAdjustment) + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq(Spree::Calculator::FlatRate) + expect(first_action_calculator.preferred_amount).to eq(4) + end + + it "allows an admin to create a promotion that adds a 'free' item to the cart" do + create(:product, name: 'RoR Mug') + fill_in 'Name', with: 'Promotion' + fill_in 'Code', with: 'complex' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + + expect(page).to have_content(promotion.name) + + select2 'Create line items', from: 'Add action of type' + + within('#action_fields') { click_button 'Add' } + + page.find('.create_line_items .select2-choice').click + page.find('.select2-input').set('RoR Mug') + page.find('.select2-highlighted').click + + within('#actions_container') { click_button 'Update' } + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#new_promotion_action_form') { click_button 'Add' } + select2 'Flat Rate', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('.create_adjustment .calculator-fields') { fill_in 'Amount', with: '40.00' } + within('#actions_container') { click_button 'Update' } + + expect(promotion.code).to eq('complex') + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateLineItems) + expect(first_action.promotion_action_line_items).not_to be_empty + end + + it 'ceasing to be eligible for a promotion with item total rule then becoming eligible again' do + fill_in 'Name', with: 'Promotion' + click_button 'Create' + promotion = Spree::Promotion.find_by(name: 'Promotion') + + expect(page).to have_content(promotion.name) + + select2 'Item total', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_min', with: '50' + eventually_fill_in 'promotion_promotion_rules_attributes_1_preferred_amount_max', with: '150' + within('#rule_fields') { click_button 'Update' } + + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + select2 'Flat Rate', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + within('.calculator-fields') { fill_in 'Amount', with: '5' } + within('#actions_container') { click_button 'Update' } + + first_rule = promotion.rules.first + expect(first_rule.class).to eq(Spree::Promotion::Rules::ItemTotal) + expect(first_rule.preferred_amount_min).to eq(50) + expect(first_rule.preferred_amount_max).to eq(150) + + first_action = promotion.actions.first + expect(first_action.class).to eq(Spree::Promotion::Actions::CreateAdjustment) + expect(first_action.calculator.class).to eq(Spree::Calculator::FlatRate) + expect(first_action.calculator.preferred_amount).to eq(5) + end + end + + context 'filtering promotions' do + let!(:promotion_category) { create(:promotion_category, name: 'Welcome Category') } + + it 'renders selected filters' do + visit spree.admin_promotions_path + + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_name_cont', with: 'welcome' + fill_in 'q_code_cont', with: 'rx01welcome' + fill_in 'q_path_cont', with: 'path_promo' + select 'Welcome Category', from: 'q_promotion_category_id_eq' + end + + click_on 'Filter Results' + + within('.table-active-filters') do + expect(page).to have_content('Name: welcome') + expect(page).to have_content('Code: rx01welcome') + expect(page).to have_content('Path: path_promo') + expect(page).to have_content('Promotion Category: Welcome Category') + end + end + end +end diff --git a/backend/spec/features/admin/promotions/option_value_rule_spec.rb b/backend/spec/features/admin/promotions/option_value_rule_spec.rb new file mode 100644 index 00000000000..2c8002e403d --- /dev/null +++ b/backend/spec/features/admin/promotions/option_value_rule_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe 'Promotion with option value rule', type: :feature do + stub_authorization! + + let(:variant) { create :variant } + let!(:product) { variant.product } + let!(:option_value) { variant.option_values.first } + + let(:promotion) { create :promotion } + + before do + visit spree.edit_admin_promotion_path(promotion) + end + + it 'adding an option value rule', js: true do + select2 'Option Value(s)', from: 'Add rule of type' + within('#rule_fields') { click_button 'Add' } + + within('#rules .promotion-block') do + click_button 'Add' + + expect(page.body).to have_content('Product') + expect(page.body).to have_content('Option Values') + end + + within('.promo-rule-option-value') do + targetted_select2_search product.name, from: '.js-promo-rule-option-value-product-select' + targetted_select2_search( + option_value.name, + from: '.js-promo-rule-option-value-option-values-select' + ) + end + + within('#rules_container') { click_button 'Update' } + + first_rule = promotion.rules.reload.first + expect(first_rule.class).to eq Spree::Promotion::Rules::OptionValue + expect(first_rule.preferred_eligible_values).to eq Hash[product.id => [option_value.id]] + end + + context 'with an existing option value rule' do + let(:variant1) { create :variant } + let(:variant2) { create :variant } + + before do + rule = Spree::Promotion::Rules::OptionValue.new + rule.promotion = promotion + rule.preferred_eligible_values = Hash[ + variant1.product_id => variant1.option_values.pluck(:id), + variant2.product_id => variant2.option_values.pluck(:id) + ] + rule.save! + + visit spree.edit_admin_promotion_path(promotion) + end + + it 'deleting a product', js: true do + within('.promo-rule-option-value:last-child') do + find('.delete').click + end + + within('#rule_fields') { click_button 'Update' } + + first_rule = promotion.rules.reload.first + expect(first_rule.preferred_eligible_values).to eq( + Hash[variant1.product_id => variant1.option_values.pluck(:id)] + ) + end + end +end diff --git a/backend/spec/features/admin/promotions/tiered_calculator_spec.rb b/backend/spec/features/admin/promotions/tiered_calculator_spec.rb new file mode 100644 index 00000000000..59348cfac17 --- /dev/null +++ b/backend/spec/features/admin/promotions/tiered_calculator_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe 'Tiered Calculator Promotions' do + stub_authorization! + + let(:promotion) { create :promotion } + + before do + visit spree.edit_admin_promotion_path(promotion) + end + + it 'adding a tiered percent calculator', js: true do + select2 'Create whole-order adjustment', from: 'Add action of type' + within('#action_fields') { click_button 'Add' } + + select2 'Tiered Percent', from: 'Calculator' + within('#actions_container') { click_button 'Update' } + + within('#actions_container .settings') do + expect(page.body).to have_content('Base Percent') + expect(page.body).to have_content('Tiers') + + click_button 'Add' + end + + fill_in 'Base Percent', with: 5 + + within('.tier') do + find('.js-base-input').set(100) + page.execute_script("$('.js-base-input').change();") + find('.js-value-input').set(10) + page.execute_script("$('.js-value-input').change();") + end + within('#actions_container') { click_button 'Update' } + + first_action = promotion.actions.first + expect(first_action.class).to eq Spree::Promotion::Actions::CreateAdjustment + + first_action_calculator = first_action.calculator + expect(first_action_calculator.class).to eq Spree::Calculator::TieredPercent + expect(first_action_calculator.preferred_base_percent).to eq 5 + expect(first_action_calculator.preferred_tiers).to eq Hash[100.0 => 10.0] + end + + context 'with an existing tiered flat rate calculator' do + let(:promotion) { create :promotion, :with_order_adjustment } + + before do + action = promotion.actions.first + + action.calculator = Spree::Calculator::TieredFlatRate.new + action.calculator.preferred_base_amount = 5 + action.calculator.preferred_tiers = Hash[100 => 10, 200 => 15, 300 => 20] + action.calculator.save! + + visit spree.edit_admin_promotion_path(promotion) + end + + it 'deleting a tier', js: true do + within('.tier:nth-child(2)') do + click_icon :delete + end + + within('#actions_container') { click_button 'Update' } + + calculator = promotion.actions.first.calculator + expect(calculator.preferred_tiers).to eq Hash[100.0 => 10.0, 300.0 => 20.0] + end + end +end diff --git a/backend/spec/features/admin/refund_reasons/refund_reasons_spec.rb b/backend/spec/features/admin/refund_reasons/refund_reasons_spec.rb new file mode 100644 index 00000000000..16f9a19ce74 --- /dev/null +++ b/backend/spec/features/admin/refund_reasons/refund_reasons_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe 'RefundReason', type: :feature, js: true do + stub_authorization! + + let!(:amount) { 100.0 } + let!(:payment_amount) { amount * 2 } + let!(:payment_method) { create(:credit_card_payment_method) } + let!(:payment) { create(:payment, amount: payment_amount, payment_method: payment_method) } + let!(:refund_reason) { create(:default_refund_reason, name: 'Reason #1', mutable: true) } + let!(:refund_reason2) { create(:refund_reason, name: 'Reason #2', mutable: true) } + + before do + create(:refund, payment: payment, amount: amount, reason: refund_reason, transaction_id: nil) + visit spree.admin_refund_reasons_path + end + + describe 'destroy' do + it 'has refund reasons' do + within('.table #refund_reasons') do + expect(page).to have_content(refund_reason.name) + expect(page).to have_content(refund_reason2.name) + end + end + + context 'should not destroy an associated option type' do + before { within_row(1) { delete_product_property } } + + it 'has persisted refund reasons' do + within('.table #refund_reasons') do + expect(page).to have_content(refund_reason.name) + expect(page).to have_content(refund_reason2.name) + end + end + + it(js: false) { expect(Spree::RefundReason.all).to include(refund_reason) } + it(js: false) { expect(Spree::RefundReason.all).to include(refund_reason2) } + end + + context 'should allow an admin to destroy a non associated option type' do + before { within_row(2) { delete_product_property } } + + it 'has persisted refund reasons' do + within('.table #refund_reasons') do + expect(page).to have_content(refund_reason.name) + expect(page).not_to have_content(refund_reason2.name) + end + end + + it(js: false) { expect(Spree::RefundReason.all).to include(refund_reason) } + it(js: false) { expect(Spree::RefundReason.all).not_to include(refund_reason2) } + end + + def delete_product_property + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + wait_for_ajax + end + end +end diff --git a/backend/spec/features/admin/reimbursement_type/edit_reimbursement_type_spec.rb b/backend/spec/features/admin/reimbursement_type/edit_reimbursement_type_spec.rb new file mode 100644 index 00000000000..0ca1a842cd7 --- /dev/null +++ b/backend/spec/features/admin/reimbursement_type/edit_reimbursement_type_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'edit reimbursement type', type: :feature do + stub_authorization! + let(:r_type) do + create(:reimbursement_type, + name: 'Exchange', + type: 'Spree::ReimbursementType::Exchange', + active: true, + mutable: true) + end + + before do + visit "/admin/reimbursement_types/#{r_type.id}/edit" + end + + context 'with valid attributes' do + it 'change name, active and mutable' do + fill_in 'Name', with: 'New Credit' + uncheck 'Mutable' + uncheck 'Active' + + expect { click_button 'Create' }.not_to change(Spree::ReimbursementType, :count) + + r_type.reload + + expect(r_type.active).to eq false + expect(r_type.mutable).to eq false + expect(page).to have_content('New Credit') + end + end + + it 'view should have select field' do + expect(page).not_to have_css('div#reimbursement_type_type_field.form-group.field') + end +end diff --git a/backend/spec/features/admin/reimbursement_type/new_reimbursement_type_spec.rb b/backend/spec/features/admin/reimbursement_type/new_reimbursement_type_spec.rb new file mode 100644 index 00000000000..bc0faa2a781 --- /dev/null +++ b/backend/spec/features/admin/reimbursement_type/new_reimbursement_type_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe 'new reimbursement type', type: :feature do + stub_authorization! + + before do + visit '/admin/reimbursement_types/new' + end + + it 'view should have select field' do + expect(page).to have_css('div#reimbursement_type_type_field.form-group.field') + end + + context 'with valid attributes' do + it 'credit type' do + fill_in 'Name', with: 'Credit' + select 'Spree::ReimbursementType::Credit', from: 'reimbursement_type_type' + + expect { click_button 'Create' }.to change(Spree::ReimbursementType, :count).by(1) + + expect(page).to have_content('Credit') + end + + it 'exchange type' do + fill_in 'Name', with: 'Exchange' + select 'Spree::ReimbursementType::Exchange', from: 'reimbursement_type_type' + + expect { click_button 'Create' }.to change(Spree::ReimbursementType, :count).by(1) + + expect(page).to have_content('Exchange') + end + + it 'original payment type' do + fill_in 'Name', with: 'OriginalPayment' + select 'Spree::ReimbursementType::OriginalPayment', from: 'reimbursement_type_type' + + expect { click_button 'Create' }.to change(Spree::ReimbursementType, :count).by(1) + + expect(page).to have_content('OriginalPayment') + end + + it 'store credit type' do + fill_in 'Name', with: 'StoreCredit' + select 'Spree::ReimbursementType::StoreCredit', from: 'reimbursement_type_type' + + expect { click_button 'Create' }.to change(Spree::ReimbursementType, :count).by(1) + + expect(page).to have_content('StoreCredit') + end + end + + context 'with invalid params' do + it 'without name' do + fill_in 'Name', with: '' + select 'Spree::ReimbursementType::StoreCredit', from: 'reimbursement_type_type' + + expect { click_button 'Create' }.not_to change(Spree::ReimbursementType, :count) + + expect(page).to have_content("Name can't be blank") + end + end +end diff --git a/backend/spec/features/admin/reports_spec.rb b/backend/spec/features/admin/reports_spec.rb new file mode 100644 index 00000000000..c150e5e2fc2 --- /dev/null +++ b/backend/spec/features/admin/reports_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe 'Reports', type: :feature do + stub_authorization! + + context 'visiting the admin reports page' do + it 'has the right content' do + visit spree.admin_path + click_link 'Reports' + click_link 'Sales Total' + + expect(page).to have_content('Sales Totals') + expect(page).to have_content('Item Total') + expect(page).to have_content('Adjustment Total') + expect(page).to have_content('Sales Total') + end + end + + context 'searching the admin reports page' do + before do + order = create(:order) + order.update_columns(adjustment_total: 100) + order.completed_at = Time.current + order.save! + + order = create(:order) + order.update_columns(adjustment_total: 200) + order.completed_at = Time.current + order.save! + + # incomplete order + order = create(:order) + order.update_columns(adjustment_total: 50) + order.save! + + order = create(:order) + order.update_columns(adjustment_total: 200) + order.completed_at = 3.years.ago + order.created_at = 3.years.ago + order.save! + + order = create(:order) + order.update_columns(adjustment_total: 200) + order.completed_at = 3.years.from_now + order.created_at = 3.years.from_now + order.save! + end + + it 'allows me to search for reports' do + visit spree.admin_path + click_link 'Reports' + click_link 'Sales Total' + + fill_in 'q_completed_at_gt', with: 1.week.ago + fill_in 'q_completed_at_lt', with: 1.week.from_now + click_button 'Search' + + expect(page).to have_content('$300.00') + end + end +end diff --git a/backend/spec/features/admin/return_authorization_reasons/return_authorization_reasons_spec.rb b/backend/spec/features/admin/return_authorization_reasons/return_authorization_reasons_spec.rb new file mode 100644 index 00000000000..51da36f79e5 --- /dev/null +++ b/backend/spec/features/admin/return_authorization_reasons/return_authorization_reasons_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe 'ReturnAuthorizationReason', type: :feature, js: true do + stub_authorization! + + let!(:order) { create(:shipped_order) } + let!(:stock_location) { create(:stock_location) } + let!(:rma_reason) { create(:return_authorization_reason, name: 'Defect #1', mutable: true) } + let!(:rma_reason2) { create(:return_authorization_reason, name: 'Defect #2', mutable: true) } + + before do + create( + :return_authorization, + order: order, + stock_location: stock_location, + reason: rma_reason + ) + + visit spree.admin_return_authorization_reasons_path + end + + describe 'destroy' do + it 'has return authorization reasons' do + within('.table #return_authorization_reasons') do + expect(page).to have_content(rma_reason.name) + expect(page).to have_content(rma_reason2.name) + end + end + + context 'should not destroy an associated option type' do + before { within_row(1) { delete_product_property } } + + it 'has persisted return authorization reasons' do + within('.table #return_authorization_reasons') do + expect(page).to have_content(rma_reason.name) + expect(page).to have_content(rma_reason2.name) + end + end + + it(js: false) { expect(Spree::ReturnAuthorizationReason.all).to include(rma_reason) } + it(js: false) { expect(Spree::ReturnAuthorizationReason.all).to include(rma_reason2) } + end + + context 'should allow an admin to destroy a non associated option type' do + before { within_row(2) { delete_product_property } } + + it 'has persisted return authorization reasons' do + within('.table #return_authorization_reasons') do + expect(page).to have_content(rma_reason.name) + expect(page).not_to have_content(rma_reason2.name) + end + end + + it(js: false) { expect(Spree::ReturnAuthorizationReason.all).to include(rma_reason) } + it(js: false) { expect(Spree::ReturnAuthorizationReason.all).not_to include(rma_reason2) } + end + + def delete_product_property + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + wait_for_ajax + end + end +end diff --git a/backend/spec/features/admin/returns/customer_returns_spec.rb b/backend/spec/features/admin/returns/customer_returns_spec.rb new file mode 100644 index 00000000000..bc74a9a1b75 --- /dev/null +++ b/backend/spec/features/admin/returns/customer_returns_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +describe 'Customer Returns', type: :feature do + stub_authorization! + let!(:customer_return) { create(:customer_return, created_at: Time.current) } + + describe 'listing' do + let!(:customer_return_2) { create(:customer_return, created_at: Time.current - 1.day) } + + before do + visit spree.admin_customer_returns_path + end + + it 'lists sorted by created_at' do + within_row(1) { expect(page).to have_content(customer_return.number) } + within_row(2) { expect(page).to have_content(customer_return_2.number) } + end + + it 'displays pre tax total' do + within_row(1) { expect(page).to have_content(customer_return.display_pre_tax_total.to_html) } + end + + it 'displays order number' do + within_row(1) { expect(page).to have_content(customer_return.order.number) } + end + + it 'displays customer return number' do + within_row(1) { expect(page).to have_content(customer_return.number) } + end + + it 'displays status' do + within_row(1) { expect(page).to have_content(Spree.t(:incomplete)) } + end + + it 'has edit link' do + expect(page).to have_css('.icon-edit') + end + end + + describe 'searching' do + let!(:customer_return_2) { create(:customer_return) } + + before do + visit spree.admin_customer_returns_path + end + + it 'searches on number' do + click_on 'Filter' + fill_in 'q_number_cont', with: customer_return.number + click_on 'Search' + + expect(page).to have_content(customer_return.number) + expect(page).not_to have_content(customer_return_2.number) + + click_on 'Filter' + fill_in 'q_number_cont', with: customer_return_2.number + click_on 'Search' + + expect(page).to have_content(customer_return_2.number) + expect(page).not_to have_content(customer_return.number) + end + + it 'renders selected filters', js: true do + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_number_cont', with: 'RX001-01' + end + + click_on 'Search' + + within('.table-active-filters') do + expect(page).to have_content('Number: RX001-01') + end + end + end + + describe 'link' do + describe 'order number' do + it 'opens orders edit page' do + visit spree.admin_customer_returns_path + click_link customer_return.order.number + expect(page).to have_content("Orders / #{customer_return.order.number}") + end + end + + describe 'customer return number' do + it 'opens customer return edit page' do + visit spree.admin_customer_returns_path + click_link customer_return.number + expect(page).to have_content("Customer Return ##{customer_return.number}") + end + end + end +end diff --git a/backend/spec/features/admin/returns/return_authorizations_spec.rb b/backend/spec/features/admin/returns/return_authorizations_spec.rb new file mode 100644 index 00000000000..4f25d7cc90a --- /dev/null +++ b/backend/spec/features/admin/returns/return_authorizations_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' + +describe 'Return Authorizations', type: :feature do + stub_authorization! + + describe 'listing' do + let!(:return_authorization) { create(:return_authorization, created_at: Time.current) } + let!(:return_authorization_2) { create(:return_authorization, created_at: Time.current - 1.day) } + + before do + visit spree.admin_return_authorizations_path + end + + it 'lists return authorizations sorted by created_at' do + within_row(1) { expect(page).to have_content(return_authorization.number) } + within_row(2) { expect(page).to have_content(return_authorization_2.number) } + end + + it 'displays order number' do + within_row(1) { expect(page).to have_content(return_authorization.order.number) } + end + + it 'displays return authorization number' do + within_row(1) { expect(page).to have_content(return_authorization.number) } + end + + it 'displays state' do + return_authorization_state = Spree.t("return_authorization_states.#{return_authorization.state}") + within_row(1) { expect(page).to have_content(return_authorization_state) } + end + + it 'has edit link' do + expect(page).to have_css('.icon-edit') + end + end + + describe 'searching' do + let!(:return_authorization) { create(:return_authorization, state: 'authorized') } + let!(:return_authorization_2) { create(:return_authorization, state: 'canceled') } + + before do + visit spree.admin_return_authorizations_path + end + + it 'searches on number' do + click_on 'Filter' + fill_in 'q_number_cont', with: return_authorization.number + click_on 'Search' + + expect(page).to have_content(return_authorization.number) + expect(page).not_to have_content(return_authorization_2.number) + + click_on 'Filter' + fill_in 'q_number_cont', with: return_authorization_2.number + click_on 'Search' + + expect(page).to have_content(return_authorization_2.number) + expect(page).not_to have_content(return_authorization.number) + end + + it 'searches on status' do + click_on 'Filter' + select Spree.t("return_authorization_states.#{return_authorization.state}"), from: 'Status' + click_on 'Search' + + expect(page).to have_content(return_authorization.number) + expect(page).not_to have_content(return_authorization_2.number) + + click_on 'Filter' + select Spree.t("return_authorization_states.#{return_authorization_2.state}"), from: 'Status' + click_on 'Search' + + expect(page).to have_content(return_authorization_2.number) + expect(page).not_to have_content(return_authorization.number) + end + + it 'renders selected filters', js: true do + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_number_cont', with: 'RX001-01' + select 'Authorized', from: 'q_state_eq' + end + + click_on 'Search' + + within('.table-active-filters') do + expect(page).to have_content('Number: RX001-01') + expect(page).to have_content('Status: Authorized') + end + end + end + + describe 'link' do + let!(:return_authorization) { create(:return_authorization) } + + describe 'order number' do + it 'opens orders edit page' do + visit spree.admin_return_authorizations_path + click_link return_authorization.order.number + expect(page).to have_content("Orders / #{return_authorization.order.number}") + end + end + + describe 'return authorization number' do + it 'opens return authorization edit page' do + visit spree.admin_return_authorizations_path + click_link return_authorization.number + expect(page).to have_content(return_authorization.number) + end + end + + describe 'authorized' do + let!(:return_authorization) { create(:return_authorization, state: 'authorized') } + let!(:return_authorization_2) { create(:return_authorization, state: 'canceled') } + + it 'only shows authorized return authorizations' do + visit spree.admin_return_authorizations_path + within('.nav-tabs') do + click_link 'Authorized' + end + + expect(page).to have_content(return_authorization.number) + expect(page).not_to have_content(return_authorization_2.number) + end + + it 'preselects authorized status in filter' do + visit spree.admin_return_authorizations_path + within('.nav-tabs') do + click_link 'Authorized' + end + + within('#table-filter') do + return_authorization_state = Spree.t("return_authorization_states.#{return_authorization.state}") + expect(page).to have_select('Status', selected: return_authorization_state) + end + end + end + + describe 'canceled' do + let!(:return_authorization) { create(:return_authorization, state: 'canceled') } + let!(:return_authorization_2) { create(:return_authorization, state: 'authorized') } + + it 'only shows canceled return authorizations' do + visit spree.admin_return_authorizations_path + within('.nav-tabs') do + click_link 'Canceled' + end + + expect(page).to have_content(return_authorization.number) + expect(page).not_to have_content(return_authorization_2.number) + end + + it 'preselects canceled status in filter' do + visit spree.admin_return_authorizations_path + within('.nav-tabs') do + click_link 'Canceled' + end + + within('#table-filter') do + return_authorization_state = Spree.t("return_authorization_states.#{return_authorization.state}") + expect(page).to have_select('Status', selected: return_authorization_state) + end + end + end + end +end diff --git a/backend/spec/features/admin/stock_transfer_spec.rb b/backend/spec/features/admin/stock_transfer_spec.rb new file mode 100644 index 00000000000..32408d3f1dd --- /dev/null +++ b/backend/spec/features/admin/stock_transfer_spec.rb @@ -0,0 +1,93 @@ +require 'spec_helper' + +describe 'Stock Transfers', type: :feature, js: true do + stub_authorization! + + it 'shows variants with options text' do + create(:stock_location_with_items, name: 'NY') + + product = Spree::Product.first + variant = create(:variant, product: product) + variant.set_option_value('Color', 'Green') + + visit spree.admin_stock_transfers_path + click_on 'New Stock Transfer' + + select2_search variant.sku, from: 'Variant' + + content = "#{variant.name} - #{variant.sku} (#{variant.options_text})" + expect(page).to have_content(content) + end + + it 'transfer between 2 locations' do + create(:stock_location_with_items, name: 'NY') # source_location + create(:stock_location, name: 'SF') # destination_location + + variant = Spree::Variant.last + + visit spree.admin_stock_transfers_path + click_on 'New Stock Transfer' + fill_in 'reference', with: 'PO 666' + + select2_search variant.name, from: 'Variant' + + click_button 'Add' + click_button 'Transfer Stock' + + expect(page).to have_content('Reference PO 666') + expect(page).to have_content('NY') + expect(page).to have_content('SF') + expect(page).to have_content(variant.name) + + transfer = Spree::StockTransfer.last + expect(transfer.stock_movements.size).to eq 2 + end + + describe 'received stock transfer' do + def it_is_received_stock_transfer(page) + expect(page).to have_content('Reference PO 666') + expect(page).not_to have_selector('#stock-location-source') + expect(page).to have_selector('#stock-location-destination') + + transfer = Spree::StockTransfer.last + expect(transfer.stock_movements.size).to eq 1 + expect(transfer.source_location).to be_nil + end + + it 'receive stock to a single location' do + create(:stock_location_with_items, name: 'NY') # source_location + create(:stock_location, name: 'SF') # destination_location + + variant = Spree::Variant.last + + visit spree.new_admin_stock_transfer_path + + fill_in 'reference', with: 'PO 666' + check 'transfer_receive_stock' + select('NY', from: 'transfer_destination_location_id') + select2_search variant.name, from: 'Variant' + + click_button 'Add' + click_button 'Transfer Stock' + + it_is_received_stock_transfer page + end + + it 'forced to only receive there is only one location' do + create(:stock_location_with_items, name: 'NY') # source_location + variant = Spree::Variant.last + + visit spree.new_admin_stock_transfer_path + + fill_in 'reference', with: 'PO 666' + + select('NY', from: 'transfer_destination_location_id') + select2_search variant.name, from: 'Variant' + + click_button 'Add' + click_button 'Transfer Stock' + + it_is_received_stock_transfer page + end + end +end diff --git a/backend/spec/features/admin/store_credits_spec.rb b/backend/spec/features/admin/store_credits_spec.rb new file mode 100644 index 00000000000..7faff90ff02 --- /dev/null +++ b/backend/spec/features/admin/store_credits_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe 'Store credits admin', type: :feature do + stub_authorization! + + let!(:admin_user) { create(:admin_user) } + let!(:store_credit) { create(:store_credit) } + + before do + allow(Spree.user_class).to receive(:find_by).and_return(store_credit.user) + end + + describe 'visiting the store credits page' do + before do + visit spree.admin_path + click_link 'Users' + end + + it 'is on the store credits page' do + click_link store_credit.user.email + click_link 'Store Credits' + expect(page).to have_current_path(spree.admin_user_store_credits_path(store_credit.user)) + + store_credit_table = page.find('table') + expect(store_credit_table.all('tr').count).to eq 1 + expect(store_credit_table).to have_content(Spree::Money.new(store_credit.amount, currency: store_credit.currency).to_s) + expect(store_credit_table).to have_content(Spree::Money.new(store_credit.amount_used, currency: store_credit.currency).to_s) + expect(store_credit_table).to have_content(store_credit.category_name) + expect(store_credit_table).to have_content(store_credit.created_by_email) + end + end + + describe 'creating store credit' do + before do + visit spree.admin_path + click_link 'Users' + click_link store_credit.user.email + click_link 'Store Credits' + allow_any_instance_of(Spree::Admin::StoreCreditsController).to receive(:try_spree_current_user).and_return(admin_user) + end + + describe 'with default currency' do + it 'creates store credit and associate it with the user' do + click_link 'Add Store Credit' + page.fill_in 'store_credit_amount', with: '102.00' + select 'Exchange', from: 'store_credit_category_id' + click_button 'Create' + + expect(page).to have_current_path(spree.admin_user_store_credits_path(store_credit.user)) + + store_credit_table = page.find('table') + expect(store_credit_table.all('tr').count).to eq 2 + expect(Spree::StoreCredit.count).to eq 2 + end + end + + describe 'with selected currency' do + it 'creates store credit and associate it with the user' do + click_link 'Add Store Credit' + page.fill_in 'store_credit_amount', with: '100.00' + select 'EUR', from: 'store_credit_currency' + select 'Exchange', from: 'store_credit_category_id' + click_button 'Create' + + expect(page).to have_current_path(spree.admin_user_store_credits_path(store_credit.user)) + + store_credit_table = page.find('table') + expect(store_credit_table.all('tr').count).to eq 2 + expect(Spree::StoreCredit.count).to eq 2 + expect(Spree::StoreCredit.last.currency).to eq 'EUR' + expect(store_credit_table).to have_content('€100.00') + end + end + end + + describe 'updating store credit' do + let(:updated_amount) { '99.0' } + + before do + visit spree.admin_path + click_link 'Users' + click_link store_credit.user.email + click_link 'Store Credits' + allow_any_instance_of(Spree::Admin::StoreCreditsController).to receive(:try_spree_current_user).and_return(admin_user) + end + + it 'creates store credit and associate it with the user' do + click_link 'Edit' + page.fill_in 'store_credit_amount', with: updated_amount + click_button 'Update' + + expect(page).to have_current_path(spree.admin_user_store_credits_path(store_credit.user)) + store_credit_table = page.find('table') + expect(store_credit_table).to have_content(Spree::Money.new(updated_amount, currency: store_credit.currency).to_s) + expect(store_credit.reload.amount.to_f).to eq updated_amount.to_f + end + end + + describe 'deleting store credit', js: true do + before do + visit spree.admin_path + click_link 'Users' + click_link store_credit.user.email + click_link 'Store Credits' + allow_any_instance_of(Spree::Admin::StoreCreditsController).to receive(:try_spree_current_user).and_return(admin_user) + end + + it 'updates store credit in lifetime stats' do + spree_accept_alert do + click_icon :delete + wait_for_ajax + end + store_credit = page.find('#user-lifetime-stats #store_credit') + expect(store_credit.text).to eq(Spree::Money.new(0).to_s) + end + end + + describe 'non-existent user' do + before do + visit spree.admin_path + click_link 'Users' + click_link store_credit.user.email + store_credit.user.destroy + allow(Spree.user_class).to receive(:find_by).and_return(nil) + click_link 'Store Credits' + allow_any_instance_of(Spree::Admin::StoreCreditsController).to receive(:try_spree_current_user).and_return(admin_user) + end + + it 'displays flash withe error' do + expect(page).to have_content(Spree.t(:user_not_found)) + end + end +end diff --git a/backend/spec/features/admin/taxons_spec.rb b/backend/spec/features/admin/taxons_spec.rb new file mode 100644 index 00000000000..2e2f3037108 --- /dev/null +++ b/backend/spec/features/admin/taxons_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe 'Taxonomies and taxons', type: :feature do + stub_authorization! + + let(:taxonomy) { create(:taxonomy, name: 'Hello') } + + it 'admin should be able to edit taxon' do + visit spree.edit_admin_taxonomy_taxon_path(taxonomy, taxonomy.root.id) + + fill_in 'taxon_name', with: 'Shirt' + fill_in 'taxon_description', with: 'Discover our new rails shirts' + + fill_in 'permalink_part', with: 'shirt-rails' + click_button 'Update' + expect(page).to have_content('Taxon "Shirt" has been successfully updated!') + end + + it 'taxon without name should not be updated' do + visit spree.edit_admin_taxonomy_taxon_path(taxonomy, taxonomy.root.id) + + fill_in 'taxon_name', with: '' + fill_in 'taxon_description', with: 'Discover our new rails shirts' + + fill_in 'permalink_part', with: 'shirt-rails' + click_button 'Update' + expect(page).to have_content("Name can't be blank") + end + + it 'admin should be able to remove a product from a taxon', js: true do + taxon_1 = create(:taxon, name: 'Clothing') + product = create(:product) + product.taxons << taxon_1 + + visit spree.admin_taxons_path + select_clothing_from_select2 + + find('.product').hover + find('.product .dropdown-toggle').click + click_link 'Delete From Taxon' + wait_for_ajax + + visit current_path + select_clothing_from_select2 + + expect(page).to have_content('No results') + end + + def select_clothing_from_select2 + targetted_select2_search 'Clothing', from: '#s2id_taxon_id' + wait_for_ajax + end +end diff --git a/backend/spec/features/admin/users_spec.rb b/backend/spec/features/admin/users_spec.rb new file mode 100644 index 00000000000..570bf7bc5de --- /dev/null +++ b/backend/spec/features/admin/users_spec.rb @@ -0,0 +1,313 @@ +require 'spec_helper' + +describe 'Users', type: :feature do + include Spree::BaseHelper + stub_authorization! + include Spree::Admin::BaseHelper + + let!(:user_a) { create(:user_with_addresses, email: 'a@example.com') } + let!(:user_b) { create(:user_with_addresses, email: 'b@example.com') } + + let(:order) { create(:completed_order_with_totals, user: user_a, number: 'R123') } + + let(:order_2) do + create(:completed_order_with_totals, user: user_a, number: 'R456').tap do |o| + li = o.line_items.last + li.update_column(:price, li.price + 10) + end + end + + let(:orders) { [order, order_2] } + + shared_examples_for 'a user page' do + it 'has lifetime stats' do + orders + visit current_url # need to refresh after creating the orders for specs that did not require orders + within('#user-lifetime-stats') do + [:total_sales, :num_orders, :average_order_value, :member_since].each do |stat_name| + expect(page).to have_content Spree.t(stat_name) + end + expect(page).to have_content (order.total + order_2.total) + expect(page).to have_content orders.count + expect(page).to have_content (orders.sum(&:total) / orders.count) + expect(page).to have_content pretty_time(user_a.created_at) + end + end + + it 'can go back to the users list' do + expect(page).to have_link Spree.t(:users), href: spree.admin_users_path + end + + it 'can navigate to the account page' do + expect(page).to have_link Spree.t(:"admin.user.account"), href: spree.edit_admin_user_path(user_a) + end + + it 'can navigate to the order history' do + expect(page).to have_link Spree.t(:"admin.user.orders"), href: spree.orders_admin_user_path(user_a) + end + + it 'can navigate to the items purchased' do + expect(page).to have_link Spree.t(:"admin.user.items"), href: spree.items_admin_user_path(user_a) + end + end + + shared_examples_for 'a sortable attribute' do + before { click_link sort_link } + + it 'can sort asc' do + within_table(table_id) do + expect(page).to have_text text_match_1 + expect(page).to have_text text_match_2 + expect(text_match_1).to appear_before text_match_2 + end + end + + it 'can sort desc' do + within_table(table_id) do + click_link sort_link + + expect(page).to have_text text_match_1 + expect(page).to have_text text_match_2 + expect(text_match_2).to appear_before text_match_1 + end + end + end + + before do + create(:country) + stub_const('Spree::User', create(:user, email: 'example@example.com').class) + visit spree.admin_path + click_link 'Users' + end + + context 'users index' do + context 'email' do + it_behaves_like 'a sortable attribute' do + let(:text_match_1) { user_a.email } + let(:text_match_2) { user_b.email } + let(:table_id) { 'listing_users' } + let(:sort_link) { 'users_email_title' } + end + end + + it 'displays the correct results for a user search' do + fill_in 'q_email_cont', with: user_a.email, visible: false + click_button 'Search', visible: false + within_table('listing_users') do + expect(page).to have_text user_a.email + expect(page).not_to have_text user_b.email + end + end + + context 'filtering users', js: true do + it 'renders selected filters' do + click_on 'Filter' + + within('#table-filter') do + fill_in 'q_email_cont', with: 'a@example.com' + fill_in 'q_bill_address_firstname_cont', with: 'John' + fill_in 'q_bill_address_lastname_cont', with: 'Doe' + fill_in 'q_bill_address_company_cont', with: 'Company' + end + + click_on 'Search' + + within('.table-active-filters') do + expect(page).to have_content('Email: a@example.com') + expect(page).to have_content('First Name: John') + expect(page).to have_content('Last Name: Doe') + expect(page).to have_content('Company: Company') + end + end + end + end + + context 'editing users' do + before { click_link user_a.email } + + it_behaves_like 'a user page' + + it 'can edit the user email' do + fill_in 'user_email', with: 'a@example.com99' + click_button 'Update' + + expect(user_a.reload.email).to eq 'a@example.com99' + expect(page).to have_text 'Account updated' + expect(find_field('user_email').value).to eq 'a@example.com99' + end + + it 'can edit the user password' do + fill_in 'user_password', with: 'welcome' + fill_in 'user_password_confirmation', with: 'welcome' + click_button 'Update' + + expect(page).to have_text 'Account updated' + end + + it 'can edit user roles' do + Spree::Role.create name: 'admin' + click_link 'Users' + click_link user_a.email + + check 'user_spree_role_admin' + click_button 'Update' + expect(page).to have_text 'Account updated' + expect(find_field('user_spree_role_admin')['checked']).to be true + end + + it 'can edit user shipping address' do + click_link 'Addresses' + + within('#admin_user_edit_addresses') do + fill_in 'user_ship_address_attributes_address1', with: '1313 Mockingbird Ln' + click_button 'Update' + expect(find_field('user_ship_address_attributes_address1').value).to eq '1313 Mockingbird Ln' + end + + expect(user_a.reload.ship_address.address1).to eq '1313 Mockingbird Ln' + end + + it 'can edit user billing address' do + click_link 'Addresses' + + within('#admin_user_edit_addresses') do + fill_in 'user_bill_address_attributes_address1', with: '1313 Mockingbird Ln' + click_button 'Update' + expect(find_field('user_bill_address_attributes_address1').value).to eq '1313 Mockingbird Ln' + end + + expect(user_a.reload.bill_address.address1).to eq '1313 Mockingbird Ln' + end + + it 'can set shipping address to be the same as billing address' do + click_link 'Addresses' + + within('#admin_user_edit_addresses') do + find('#user_use_billing').click + click_button 'Update' + end + + expect(user_a.reload.ship_address == user_a.reload.bill_address).to eq true + end + + context 'no api key exists' do + it 'can generate a new api key' do + within('#admin_user_edit_api_key') do + expect(user_a.spree_api_key).to be_blank + click_button Spree.t('generate_key', scope: 'api') + end + + expect(user_a.reload.spree_api_key).to be_present + + within('#admin_user_edit_api_key') do + expect(find('#current-api-key').text).to match(/Key: #{user_a.spree_api_key}/) + end + end + end + + context 'an api key exists' do + before do + user_a.generate_spree_api_key! + expect(user_a.reload.spree_api_key).to be_present + visit current_path + end + + it 'can clear an api key' do + within('#admin_user_edit_api_key') do + click_button Spree.t('clear_key', scope: 'api') + end + + expect(user_a.reload.spree_api_key).to be_blank + expect { find('#current-api-key') }.to raise_error Capybara::ElementNotFound + end + + it 'can regenerate an api key' do + old_key = user_a.spree_api_key + + within('#admin_user_edit_api_key') do + click_button Spree.t('regenerate_key', scope: 'api') + end + + expect(user_a.reload.spree_api_key).to be_present + expect(user_a.reload.spree_api_key).not_to eq old_key + + within('#admin_user_edit_api_key') do + expect(find('#current-api-key').text).to match(/Key: #{user_a.spree_api_key}/) + end + end + end + end + + context 'order history with sorting' do + before do + orders + click_link user_a.email + within('#sidebar') { click_link Spree.t(:"admin.user.orders") } + end + + it_behaves_like 'a user page' + + context 'completed_at' do + it_behaves_like 'a sortable attribute' do + let(:text_match_1) { order_time(order.completed_at) } + let(:text_match_2) { order_time(order_2.completed_at) } + let(:table_id) { 'listing_orders' } + let(:sort_link) { 'orders_completed_at_title' } + end + end + + [:number, :state, :total].each do |attr| + context attr do + it_behaves_like 'a sortable attribute' do + let(:text_match_1) { order.send(attr).to_s } + let(:text_match_2) { order_2.send(attr).to_s } + let(:table_id) { 'listing_orders' } + let(:sort_link) { "orders_#{attr}_title" } + end + end + end + end + + context 'items purchased with sorting' do + before do + orders + click_link user_a.email + within('#sidebar') { click_link Spree.t(:"admin.user.items") } + end + + it_behaves_like 'a user page' + + context 'completed_at' do + it_behaves_like 'a sortable attribute' do + let(:text_match_1) { order_time(order.completed_at) } + let(:text_match_2) { order_time(order_2.completed_at) } + let(:table_id) { 'listing_items' } + let(:sort_link) { 'orders_completed_at_title' } + end + end + + [:number, :state].each do |attr| + context attr do + it_behaves_like 'a sortable attribute' do + let(:text_match_1) { order.send(attr).to_s } + let(:text_match_2) { order_2.send(attr).to_s } + let(:table_id) { 'listing_items' } + let(:sort_link) { "orders_#{attr}_title" } + end + end + end + + it 'has item attributes' do + items = order.line_items | order_2.line_items + expect(page).to have_table 'listing_items' + within_table('listing_items') do + items.each do |item| + expect(page).to have_selector('.item-name', text: item.product.name) + expect(page).to have_selector('.item-price', text: item.single_money.to_html) + expect(page).to have_selector('.item-quantity', text: item.quantity) + expect(page).to have_selector('.item-total', text: item.money.to_html) + end + end + end + end +end diff --git a/backend/spec/helpers/spree/admin/base_helper_spec.rb b/backend/spec/helpers/spree/admin/base_helper_spec.rb new file mode 100644 index 00000000000..0a195988a63 --- /dev/null +++ b/backend/spec/helpers/spree/admin/base_helper_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Spree::Admin::BaseHelper, type: :helper do + include Spree::Admin::BaseHelper + + context '#datepicker_field_value' do + it 'returns nil when date is empty' do + date = nil + expect(datepicker_field_value(date)).to be_nil + end + + it 'returns a formatted date when date is present' do + date = '2013-08-14'.to_time + expect(datepicker_field_value(date)).to eq('2013/08/14') + end + end + + context '#plural_resource_name' do + it 'returns correct form of class' do + resource_class = Spree::Product + expect(plural_resource_name(resource_class)).to eq('Products') + end + end + + context '#order_time' do + it 'prints in a format' do + expect(order_time(Time.new(2016, 5, 6, 13, 33))).to eq '2016-05-06 1:33 PM' + end + end +end diff --git a/backend/spec/helpers/spree/admin/navigation_helper_spec.rb b/backend/spec/helpers/spree/admin/navigation_helper_spec.rb new file mode 100644 index 00000000000..c21de910584 --- /dev/null +++ b/backend/spec/helpers/spree/admin/navigation_helper_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe Spree::Admin::NavigationHelper, type: :helper do + before do + # `spree` route helper is not accessible in `type: :helper` hence extending it explicitly + # https://github.com/rspec/rspec-rails/issues/1626 + helper.extend Spree::TestingSupport::UrlHelpers + end + + describe '#tab' do + before do + allow(helper).to receive(:cannot?).and_return false + end + + context 'creating an admin tab' do + it "capitalizes the first letter of each word in the tab's label" do + admin_tab = helper.tab(:orders) + expect(admin_tab).to include('Orders') + end + end + + it 'accepts options with label and capitalize each word of it' do + admin_tab = helper.tab(:orders, label: 'delivered orders') + expect(admin_tab).to include('Delivered Orders') + end + + it 'capitalizes words with unicode characters' do + # overview + admin_tab = helper.tab(:orders, label: 'přehled') + expect(admin_tab).to include('Přehled') + end + + describe 'selection' do + context 'when match_path option is not supplied' do + subject(:tab) { helper.tab(:orders) } + + it 'is selected if the controller matches' do + allow(controller).to receive(:controller_name).and_return('orders') + expect(subject).to include('selected') + end + + it 'is not selected if the controller does not match' do + allow(controller).to receive(:controller_name).and_return('bonobos') + expect(subject).not_to include('selected') + end + end + + context 'when match_path option is supplied' do + before do + allow(helper).to receive(:request).and_return(double(ActionDispatch::Request, fullpath: '/admin/orders/edit/1')) + end + + it 'is selected if the fullpath matches' do + allow(controller).to receive(:controller_name).and_return('bonobos') + tab = helper.tab(:orders, label: 'delivered orders', match_path: '/orders') + expect(tab).to include('selected') + end + + it 'is selected if the fullpath matches a regular expression' do + allow(controller).to receive(:controller_name).and_return('bonobos') + tab = helper.tab(:orders, label: 'delivered orders', match_path: /orders$|orders\//) + expect(tab).to include('selected') + end + + it 'is not selected if the fullpath does not match' do + allow(controller).to receive(:controller_name).and_return('bonobos') + tab = helper.tab(:orders, label: 'delivered orders', match_path: '/shady') + expect(tab).not_to include('selected') + end + + it 'is not selected if the fullpath does not match a regular expression' do + allow(controller).to receive(:controller_name).and_return('bonobos') + tab = helper.tab(:orders, label: 'delivered orders', match_path: /shady$|shady\//) + expect(tab).not_to include('selected') + end + end + end + end + + describe '#klass_for' do + it 'returns correct klass for Spree model' do + expect(klass_for(:products)).to eq(Spree::Product) + expect(klass_for(:product_properties)).to eq(Spree::ProductProperty) + end + + it 'returns correct klass for non-spree model' do + class MyUser + end + expect(klass_for(:my_users)).to eq(MyUser) + + Object.send(:remove_const, 'MyUser') + end + + it 'returns correct namespaced klass for non-spree model' do + module My + class User + end + end + + expect(klass_for(:my_users)).to eq(My::User) + + My.send(:remove_const, 'User') + Object.send(:remove_const, 'My') + end + end +end diff --git a/backend/spec/helpers/spree/admin/promotion_rules_helper_spec.rb b/backend/spec/helpers/spree/admin/promotion_rules_helper_spec.rb new file mode 100644 index 00000000000..3197763e93a --- /dev/null +++ b/backend/spec/helpers/spree/admin/promotion_rules_helper_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' +module Spree + describe Spree::Admin::PromotionRulesHelper, type: :helper do + it 'does not include existing rules in options' do + promotion = Spree::Promotion.new + promotion.promotion_rules << Spree::Promotion::Rules::ItemTotal.new + + options = helper.options_for_promotion_rule_types(promotion) + expect(options).not_to match(/ItemTotal/) + end + end +end diff --git a/backend/spec/helpers/spree/admin/stock_movements_helper_spec.rb b/backend/spec/helpers/spree/admin/stock_movements_helper_spec.rb new file mode 100644 index 00000000000..d76be03b532 --- /dev/null +++ b/backend/spec/helpers/spree/admin/stock_movements_helper_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Spree::Admin::StockMovementsHelper, type: :helper do + describe '#pretty_originator' do + context 'transfering between two locations' do + let(:destination_location) { create(:stock_location_with_items) } + let(:source_location) { create(:stock_location_with_items) } + let(:stock_item) { source_location.stock_items.order(:id).first } + let(:variant) { stock_item.variant } + + before do + @stock_transfer = Spree::StockTransfer.create(reference: 'PO123') + variants = { variant => 5 } + @stock_transfer.transfer(source_location, + destination_location, + variants) + helper.pretty_originator(@stock_transfer.stock_movements.last) + end + + it 'returns link to stock transfer' do + expect(helper.pretty_originator(@stock_transfer.stock_movements.last)).to eq @stock_transfer.number + end + end + end +end diff --git a/backend/spec/models/spree/resource_spec.rb b/backend/spec/models/spree/resource_spec.rb new file mode 100644 index 00000000000..ddbf48b0483 --- /dev/null +++ b/backend/spec/models/spree/resource_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +module Spree + class Test + end + module Submodule + class Test + end + end +end + +module Spree + module Admin + describe Resource, type: :model do + let(:resource_base) { Resource.new('spree/admin/test', 'test', 'widget') } + let(:resource_submodule) { Resource.new('spree/admin/submodule/test', 'test', 'widget') } + let(:resource_object_name) { Resource.new('spree/admin/test', 'test', 'gadget', 'widget') } + + it 'can get base model class' do + expect(resource_base.model_class).to eq(Spree::Test) + end + + it 'can get submodule class' do + expect(resource_submodule.model_class).to eq(Spree::Submodule::Test) + end + + it 'can get base model name (only used for parent)' do + expect(resource_base.model_name).to eq('widget') + end + + it 'can get submodule model name (only used for parent)' do + expect(resource_submodule.model_name).to eq('widget') + end + + it 'can get base object name' do + expect(resource_base.object_name).to eq('test') + end + + it 'can get submodule object name' do + expect(resource_submodule.object_name).to eq('submodule_test') + end + + it 'can get overridden object name' do + expect(resource_object_name.object_name).to eq('widget') + end + end + end +end diff --git a/backend/spec/routing/admin_path_spec.rb b/backend/spec/routing/admin_path_spec.rb new file mode 100644 index 00000000000..365511ba290 --- /dev/null +++ b/backend/spec/routing/admin_path_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +module Spree + module Admin + RSpec.describe 'AdminPath', type: :routing do + it 'shoud route to admin by default' do + expect(spree.admin_path).to eq('/admin') + end + + it 'routes to the the configured path' do + Spree.admin_path = '/secret' + Rails.application.reload_routes! + expect(spree.admin_path).to eq('/secret') + + # restore the path for other tests + Spree.admin_path = '/admin' + Rails.application.reload_routes! + expect(spree.admin_path).to eq('/admin') + end + end + end +end diff --git a/backend/spec/spec_helper.rb b/backend/spec/spec_helper.rb new file mode 100644 index 00000000000..63f7b41d4c9 --- /dev/null +++ b/backend/spec/spec_helper.rb @@ -0,0 +1,139 @@ +if ENV['COVERAGE'] + # Run Coverage report + require 'simplecov' + SimpleCov.start 'rails' do + add_group 'Libraries', 'lib/spree' + + add_filter '/bin/' + add_filter '/db/' + add_filter '/script/' + add_filter '/spec/' + add_filter '/lib/generators/' + + coverage_dir "#{ENV['COVERAGE_DIR']}/backend" if ENV['COVERAGE_DIR'] + end +end + +# This file is copied to ~/spec when you run 'ruby script/generate rspec' +# from the project root directory. +ENV['RAILS_ENV'] ||= 'test' + +begin + require File.expand_path('../dummy/config/environment', __FILE__) +rescue LoadError + puts 'Could not load dummy application. Please ensure you have run `bundle exec rake test_app`' + exit +end + +require 'rspec/rails' + +# Requires supporting files with custom matchers and macros, etc, +# in ./support/ and its subdirectories. +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } + +require 'database_cleaner' +require 'ffaker' +require 'rspec/retry' + +require 'spree/testing_support/authorization_helpers' +require 'spree/testing_support/factories' +require 'spree/testing_support/preferences' +require 'spree/testing_support/controller_requests' +require 'spree/testing_support/flash' +require 'spree/testing_support/url_helpers' +require 'spree/testing_support/order_walkthrough' +require 'spree/testing_support/capybara_ext' +require 'spree/testing_support/capybara_config' +require 'spree/testing_support/image_helpers' + +require 'spree/core/controller_helpers/strong_parameters' + +RSpec.configure do |config| + config.color = true + config.default_formatter = 'doc' + config.fail_fast = ENV['FAIL_FAST'] || false + config.infer_spec_type_from_file_location! + config.mock_with :rspec + config.raise_errors_for_deprecations! + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, comment the following line or assign false + # instead of true. + config.use_transactional_fixtures = false + + config.before :suite do + Capybara.match = :prefer_exact + DatabaseCleaner.clean_with :truncation + end + + config.before do + Rails.cache.clear + WebMock.disable! + DatabaseCleaner.strategy = if RSpec.current_example.metadata[:js] + :truncation + else + :transaction + end + # TODO: Find out why open_transactions ever gets below 0 + # See issue #3428 + ApplicationRecord.connection.increment_open_transactions if ApplicationRecord.connection.open_transactions < 0 + + DatabaseCleaner.start + reset_spree_preferences + end + + config.after do + # wait_for_ajax sometimes fails so we should clean db first to get rid of false failed specs + DatabaseCleaner.clean + + # Ensure js requests finish processing before advancing to the next test + wait_for_ajax if RSpec.current_example.metadata[:js] + end + + config.after(:each, type: :feature) do |example| + missing_translations = page.body.scan(/translation missing: #{I18n.locale}\.(.*?)[\s<\"&]/) + if missing_translations.any? + puts "Found missing translations: #{missing_translations.inspect}" + puts "In spec: #{example.location}" + end + end + + config.include FactoryBot::Syntax::Methods + + config.include Spree::TestingSupport::Preferences + config.include Spree::TestingSupport::UrlHelpers + config.include Spree::TestingSupport::ControllerRequests, type: :controller + config.include Spree::TestingSupport::Flash + config.include Spree::TestingSupport::ImageHelpers + + config.include Spree::Core::ControllerHelpers::StrongParameters, type: :controller + + config.include VersionCake::TestHelpers, type: :controller + config.before(:each, type: :controller) do + set_request_version('', 1) + end + + config.verbose_retry = true + config.display_try_failure_messages = true + + config.around :each, type: :feature do |ex| + ex.run_with_retry retry: 3 + end + + config.order = :random + Kernel.srand config.seed +end + +module Spree + module TestingSupport + module Flash + def assert_flash_success(flash) + flash = convert_flash(flash) + + within('.alert-success') do + expect(page).to have_content(flash) + end + end + end + end +end diff --git a/backend/spec/support/appear_before_matcher.rb b/backend/spec/support/appear_before_matcher.rb new file mode 100644 index 00000000000..85701baa0cd --- /dev/null +++ b/backend/spec/support/appear_before_matcher.rb @@ -0,0 +1,9 @@ +require 'rspec/expectations' + +RSpec::Matchers.define :appear_before do |expected| + match do |actual| + raise 'Page instance required to use the appear_before matcher' unless page + + page.body.index(actual) <= page.body.index(expected) + end +end diff --git a/backend/spec/support/ror_ringer.jpeg b/backend/spec/support/ror_ringer.jpeg new file mode 100644 index 00000000000..68009b1b7a1 Binary files /dev/null and b/backend/spec/support/ror_ringer.jpeg differ diff --git a/backend/spec/test_views/spree/admin/dummy_models/edit.html.erb b/backend/spec/test_views/spree/admin/dummy_models/edit.html.erb new file mode 100644 index 00000000000..366226fb970 --- /dev/null +++ b/backend/spec/test_views/spree/admin/dummy_models/edit.html.erb @@ -0,0 +1 @@ +edit diff --git a/backend/spec/test_views/spree/admin/dummy_models/new.html.erb b/backend/spec/test_views/spree/admin/dummy_models/new.html.erb new file mode 100644 index 00000000000..3e757656cf3 --- /dev/null +++ b/backend/spec/test_views/spree/admin/dummy_models/new.html.erb @@ -0,0 +1 @@ +new diff --git a/backend/spec/test_views/spree/admin/submodule/posts/edit.html.erb b/backend/spec/test_views/spree/admin/submodule/posts/edit.html.erb new file mode 100644 index 00000000000..8491ab9f808 --- /dev/null +++ b/backend/spec/test_views/spree/admin/submodule/posts/edit.html.erb @@ -0,0 +1 @@ +edit \ No newline at end of file diff --git a/backend/spec/test_views/spree/admin/submodule/posts/new.html.erb b/backend/spec/test_views/spree/admin/submodule/posts/new.html.erb new file mode 100644 index 00000000000..3e5126c4e76 --- /dev/null +++ b/backend/spec/test_views/spree/admin/submodule/posts/new.html.erb @@ -0,0 +1 @@ +new \ No newline at end of file diff --git a/backend/spree_backend.gemspec b/backend/spree_backend.gemspec new file mode 100644 index 00000000000..c4385c0676d --- /dev/null +++ b/backend/spree_backend.gemspec @@ -0,0 +1,29 @@ +# encoding: UTF-8 +require_relative '../core/lib/spree/core/version.rb' + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = 'spree_backend' + s.version = Spree.version + s.summary = 'backend e-commerce functionality for the Spree project.' + s.description = 'Required dependency for Spree' + + s.required_ruby_version = '>= 2.3.3' + + s.author = 'Sean Schofield' + s.email = 'sean@spreecommerce.com' + s.homepage = 'http://spreecommerce.org' + s.license = 'BSD-3-Clause' + + s.files = `git ls-files`.split("\n").reject { |f| f.match(/^spec/) && !f.match(/^spec\/fixtures/) } + s.require_path = 'lib' + s.requirements << 'none' + + s.add_dependency 'spree_api', s.version + s.add_dependency 'spree_core', s.version + + s.add_dependency 'bootstrap-sass', '~> 3.4' + s.add_dependency 'jquery-rails', '~> 4.3' + s.add_dependency 'jquery-ui-rails', '~> 6.0.1' + s.add_dependency 'select2-rails', '3.5.9.1' # 3.5.9.2 breaks several specs +end diff --git a/backend/vendor/assets/javascripts/handlebars.js b/backend/vendor/assets/javascripts/handlebars.js new file mode 100644 index 00000000000..870199af461 --- /dev/null +++ b/backend/vendor/assets/javascripts/handlebars.js @@ -0,0 +1,29 @@ +/**! + + @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat + handlebars v5.0.0-alpha.1 + +Copyright (C) 2011-2017 by Yehuda Katz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*/ +!function(a,b){"object"==typeof exports&&"object"==typeof module?module.exports=b():"function"==typeof define&&define.amd?define([],b):"object"==typeof exports?exports.Handlebars=b():a.Handlebars=b()}(this,function(){return function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)}([function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(){var a=r();return a.compile=function(b,c){return k.compile(b,c,a)},a.precompile=function(b,c){return k.precompile(b,c,a)},a.AST=i["default"],a.Compiler=k.Compiler,a.JavaScriptCompiler=m["default"],a.Parser=j.parser,a.parse=j.parse,a}b.__esModule=!0;var f=c(1),g=d(f),h=c(19),i=d(h),j=c(20),k=c(25),l=c(26),m=d(l),n=c(23),o=d(n),p=c(18),q=d(p),r=g["default"].create,s=e();s.create=e,q["default"](s),s.Visitor=o["default"],s["default"]=s,b["default"]=s,a.exports=b["default"]},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(a){if(a&&a.__esModule)return a;var b={};if(null!=a)for(var c in a)Object.prototype.hasOwnProperty.call(a,c)&&(b[c]=a[c]);return b["default"]=a,b}function f(){var a=new h.HandlebarsEnvironment;return n.extend(a,h),a.SafeString=j["default"],a.Exception=l["default"],a.Utils=n,a.escapeExpression=n.escapeExpression,a.VM=p,a.template=function(b){return p.template(b,a)},a}b.__esModule=!0;var g=c(2),h=e(g),i=c(16),j=d(i),k=c(4),l=d(k),m=c(3),n=e(m),o=c(17),p=e(o),q=c(18),r=d(q),s=f();s.create=f,r["default"](s),s["default"]=s,b["default"]=s,a.exports=b["default"]},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(a,b,c){this.helpers=a||{},this.partials=b||{},this.decorators=c||{},i.registerDefaultHelpers(this),j.registerDefaultDecorators(this)}b.__esModule=!0,b.HandlebarsEnvironment=e;var f=c(3),g=c(4),h=d(g),i=c(5),j=c(13),k=c(15),l=d(k),m="4.0.10";b.VERSION=m;var n=7;b.COMPILER_REVISION=n;var o={1:"<= 1.0.rc.2",2:"== 1.0.0-rc.3",3:"== 1.0.0-rc.4",4:"== 1.x.x",5:"== 2.0.0-alpha.x",6:">= 2.0.0-beta.1",7:">= 4.0.0"};b.REVISION_CHANGES=o;var p="[object Object]";e.prototype={constructor:e,logger:l["default"],log:l["default"].log,registerHelper:function(a,b){if(f.toString.call(a)===p){if(b)throw new h["default"]("Arg not supported with multiple helpers");f.extend(this.helpers,a)}else this.helpers[a]=b},unregisterHelper:function(a){delete this.helpers[a]},registerPartial:function(a,b){if(f.toString.call(a)===p)f.extend(this.partials,a);else{if("undefined"==typeof b)throw new h["default"]('Attempting to register a partial called "'+a+'" as undefined');this.partials[a]=b}},unregisterPartial:function(a){delete this.partials[a]},registerDecorator:function(a,b){if(f.toString.call(a)===p){if(b)throw new h["default"]("Arg not supported with multiple decorators");f.extend(this.decorators,a)}else this.decorators[a]=b},unregisterDecorator:function(a){delete this.decorators[a]}};var q=l["default"].log;b.log=q,b.createFrame=f.createFrame,b.logger=l["default"]},function(a,b){"use strict";function c(a){return i[a]}function d(a){for(var b=1;b":">",'"':""","'":"'","`":"`","=":"="},j=/[&<>"'`=]/g,k=/[&<>"'`=]/,l=Object.prototype.toString;b.toString=l;var m=function(a){return"function"==typeof a};m(/x/)&&(b.isFunction=m=function(a){return"function"==typeof a&&"[object Function]"===l.call(a)}),b.isFunction=m;var n=Array.isArray||function(a){return!(!a||"object"!=typeof a)&&"[object Array]"===l.call(a)};b.isArray=n},function(a,b){"use strict";function c(a,b){var e=b&&b.loc,f=void 0,g=void 0;e&&(f=e.start.line,g=e.start.column,a+=" - "+f+":"+g);for(var h=Error.prototype.constructor.call(this,a),i=0;i0?a.helpers.each(b,c):e(this):f(b,c)})},a.exports=b["default"]},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}b.__esModule=!0;var e=c(3),f=c(4),g=d(f);b["default"]=function(a){a.registerHelper("each",function(a,b){function c(b,c,e){j&&(j.key=b,j.index=c,j.first=0===c,j.last=!!e),i+=d(a[b],{data:j,blockParams:[a[b],b]})}if(!b)throw new g["default"]("Must pass iterator to #each");var d=b.fn,f=b.inverse,h=0,i="",j=void 0;if(e.isFunction(a)&&(a=a.call(this)),b.data&&(j=e.createFrame(b.data)),a&&"object"==typeof a)if(e.isArray(a))for(var k=a.length;h=0?b:parseInt(a,10)}return a},log:function(a){if(a=e.lookupLevel(a),"undefined"!=typeof console&&e.lookupLevel(e.level)<=a){var b=e.methodMap[a];console[b]||(b="log");for(var c=arguments.length,d=Array(c>1?c-1:0),f=1;fk&&A.push("'"+this.terminals_[x]+"'");D=n.showPosition?"Parse error on line "+(h+1)+":\n"+n.showPosition()+"\nExpecting "+A.join(", ")+", got '"+(this.terminals_[s]||s)+"'":"Parse error on line "+(h+1)+": Unexpected "+(s==l?"end of input":"'"+(this.terminals_[s]||s)+"'"),this.parseError(D,{text:n.match,token:this.terminals_[s]||s,line:n.yylineno,loc:q,expected:A})}if(v[0]instanceof Array&&v.length>1)throw new Error("Parse Error: multiple actions possible at state: "+u+", token: "+s);switch(v[0]){case 1:c.push(s),d.push(n.yytext),e.push(n.yylloc),c.push(v[1]),s=null,t?(s=t,t=null):(i=n.yyleng,g=n.yytext,h=n.yylineno,q=n.yylloc,j>0&&j--);break;case 2:if(y=this.productions_[v[1]][1],C.$=d[d.length-y],C._$={first_line:e[e.length-(y||1)].first_line,last_line:e[e.length-1].last_line,first_column:e[e.length-(y||1)].first_column,last_column:e[e.length-1].last_column},r&&(C._$.range=[e[e.length-(y||1)].range[0],e[e.length-1].range[1]]),w=this.performAction.apply(C,[g,i,h,o.yy,v[1],d,e].concat(m)),"undefined"!=typeof w)return w;y&&(c=c.slice(0,-1*y*2),d=d.slice(0,-1*y),e=e.slice(0,-1*y)),c.push(this.productions_[v[1]][0]),d.push(C.$),e.push(C._$),z=f[c[c.length-2]][c[c.length-1]],c.push(z);break;case 3:return!0}}return!0}},K=function(){var a={EOF:1,parseError:function(a,b){if(!this.yy.parser)throw new Error(a);this.yy.parser.parseError(a,b)},setInput:function(a,b){return this.yy=b||this.yy||{},this._input=a,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},input:function(){var a=this._input[0];this.yytext+=a,this.yyleng++,this.offset++,this.match+=a,this.matched+=a;var b=a.match(/(?:\r\n?|\n).*/g);return b?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),a},unput:function(a){var b=a.length,c=a.split(/(?:\r\n?|\n)/g);this._input=a+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-b),this.offset-=b;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),c.length-1&&(this.yylineno-=c.length-1);var e=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:c?(c.length===d.length?this.yylloc.first_column:0)+d[d.length-c.length].length-c[0].length:this.yylloc.first_column-b},this.options.ranges&&(this.yylloc.range=[e[0],e[0]+this.yyleng-b]),this.yyleng=this.yytext.length,this},more:function(){return this._more=!0,this},reject:function(){return this.options.backtrack_lexer?(this._backtrack=!0,this):this.parseError("Lexical error on line "+(this.yylineno+1)+". You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n"+this.showPosition(),{text:"",token:null,line:this.yylineno})},less:function(a){this.unput(this.match.slice(a))},pastInput:function(){var a=this.matched.substr(0,this.matched.length-this.match.length);return(a.length>20?"...":"")+a.substr(-20).replace(/\n/g,"")},upcomingInput:function(){var a=this.match;return a.length<20&&(a+=this._input.substr(0,20-a.length)),(a.substr(0,20)+(a.length>20?"...":"")).replace(/\n/g,"")},showPosition:function(){var a=this.pastInput(),b=new Array(a.length+1).join("-");return a+this.upcomingInput()+"\n"+b+"^"},test_match:function(a,b){var c,d,e;if(this.options.backtrack_lexer&&(e={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(e.yylloc.range=this.yylloc.range.slice(0))),d=a[0].match(/(?:\r\n?|\n).*/g),d&&(this.yylineno+=d.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:d?d[d.length-1].length-d[d.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+a[0].length},this.yytext+=a[0],this.match+=a[0],this.matches=a,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(a[0].length),this.matched+=a[0],c=this.performAction.call(this,this.yy,this,b,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),c)return c;if(this._backtrack){for(var f in e)this[f]=e[f];return!1}return!1},next:function(){if(this.done)return this.EOF;this._input||(this.done=!0);var a,b,c,d;this._more||(this.yytext="",this.match="");for(var e=this._currentRules(),f=0;fb[0].length)){if(b=c,d=f,this.options.backtrack_lexer){if(a=this.test_match(c,e[f]),a!==!1)return a;if(this._backtrack){b=!1;continue}return!1}if(!this.options.flex)break}return b?(a=this.test_match(b,e[d]),a!==!1&&a):""===this._input?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+". Unrecognized text.\n"+this.showPosition(),{text:"",token:null,line:this.yylineno})},lex:function(){var a=this.next();return a?a:this.lex()},begin:function(a){this.conditionStack.push(a)},popState:function(){var a=this.conditionStack.length-1;return a>0?this.conditionStack.pop():this.conditionStack[0]},_currentRules:function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},topState:function(a){return a=this.conditionStack.length-1-Math.abs(a||0),a>=0?this.conditionStack[a]:"INITIAL"},pushState:function(a){this.begin(a)},stateStackSize:function(){return this.conditionStack.length},options:{},performAction:function(a,b,c,d){function e(a,c){return b.yytext=b.yytext.substr(a,b.yyleng-c)}switch(c){case 0:if("\\\\"===b.yytext.slice(-2)?(e(0,1),this.begin("mu")):"\\"===b.yytext.slice(-1)?(e(0,1),this.begin("emu")):this.begin("mu"),b.yytext)return 15; +break;case 1:return 15;case 2:return this.popState(),15;case 3:return this.begin("raw"),15;case 4:return this.popState(),"raw"===this.conditionStack[this.conditionStack.length-1]?15:(b.yytext=b.yytext.substr(5,b.yyleng-9),18);case 5:return 15;case 6:return this.popState(),14;case 7:return 65;case 8:return 68;case 9:return 19;case 10:return this.popState(),this.begin("raw"),23;case 11:return 55;case 12:return 60;case 13:return 29;case 14:return 47;case 15:return this.popState(),44;case 16:return this.popState(),44;case 17:return 34;case 18:return 39;case 19:return 51;case 20:return 48;case 21:this.unput(b.yytext),this.popState(),this.begin("com");break;case 22:return this.popState(),14;case 23:return 48;case 24:return 73;case 25:return 72;case 26:return 72;case 27:return 87;case 28:break;case 29:return this.popState(),54;case 30:return this.popState(),33;case 31:return b.yytext=e(1,2).replace(/\\"/g,'"'),80;case 32:return b.yytext=e(1,2).replace(/\\'/g,"'"),80;case 33:return 85;case 34:return 82;case 35:return 82;case 36:return 83;case 37:return 84;case 38:return 81;case 39:return 75;case 40:return 77;case 41:return 72;case 42:return b.yytext=b.yytext.replace(/\\([\\\]])/g,"$1"),72;case 43:return"INVALID";case 44:return 5}},rules:[/^(?:[^\x00]*?(?=(\{\{)))/,/^(?:[^\x00]+)/,/^(?:[^\x00]{2,}?(?=(\{\{|\\\{\{|\\\\\{\{|$)))/,/^(?:\{\{\{\{(?=[^\/]))/,/^(?:\{\{\{\{\/[^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=[=}\s\/.])\}\}\}\})/,/^(?:[^\x00]*?(?=(\{\{\{\{)))/,/^(?:[\s\S]*?--(~)?\}\})/,/^(?:\()/,/^(?:\))/,/^(?:\{\{\{\{)/,/^(?:\}\}\}\})/,/^(?:\{\{(~)?>)/,/^(?:\{\{(~)?#>)/,/^(?:\{\{(~)?#\*?)/,/^(?:\{\{(~)?\/)/,/^(?:\{\{(~)?\^\s*(~)?\}\})/,/^(?:\{\{(~)?\s*else\s*(~)?\}\})/,/^(?:\{\{(~)?\^)/,/^(?:\{\{(~)?\s*else\b)/,/^(?:\{\{(~)?\{)/,/^(?:\{\{(~)?&)/,/^(?:\{\{(~)?!--)/,/^(?:\{\{(~)?![\s\S]*?\}\})/,/^(?:\{\{(~)?\*?)/,/^(?:=)/,/^(?:\.\.)/,/^(?:\.(?=([=~}\s\/.)|])))/,/^(?:[\/.])/,/^(?:\s+)/,/^(?:\}(~)?\}\})/,/^(?:(~)?\}\})/,/^(?:"(\\["]|[^"])*")/,/^(?:'(\\[']|[^'])*')/,/^(?:@)/,/^(?:true(?=([~}\s)])))/,/^(?:false(?=([~}\s)])))/,/^(?:undefined(?=([~}\s)])))/,/^(?:null(?=([~}\s)])))/,/^(?:-?[0-9]+(?:\.[0-9]+)?(?=([~}\s)])))/,/^(?:as\s+\|)/,/^(?:\|)/,/^(?:([^\s!"#%-,\.\/;->@\[-\^`\{-~]+(?=([=~}\s\/.)|]))))/,/^(?:\[(\\\]|[^\]])*\])/,/^(?:.)/,/^(?:$)/],conditions:{mu:{rules:[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44],inclusive:!1},emu:{rules:[2],inclusive:!1},com:{rules:[6],inclusive:!1},raw:{rules:[3,4,5],inclusive:!1},INITIAL:{rules:[0,1,44],inclusive:!0}}};return a}();return J.lexer=K,a.prototype=J,J.Parser=a,new a}();b["default"]=c,a.exports=b["default"]},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(){var a=arguments.length<=0||void 0===arguments[0]?{}:arguments[0];this.options=a}function f(a,b,c){void 0===b&&(b=a.length);var d=a[b-1],e=a[b-2];return d?"ContentStatement"===d.type?(e||!c?/\r?\n\s*?$/:/(^|\r?\n)\s*?$/).test(d.original):void 0:c}function g(a,b,c){void 0===b&&(b=-1);var d=a[b+1],e=a[b+2];return d?"ContentStatement"===d.type?(e||!c?/^\s*?\r?\n/:/^\s*?(\r?\n|$)/).test(d.original):void 0:c}function h(a,b,c){var d=a[null==b?0:b+1];if(d&&"ContentStatement"===d.type&&(c||!d.rightStripped)){var e=d.value;d.value=d.value.replace(c?/^\s+/:/^[ \t]*\r?\n?/,""),d.rightStripped=d.value!==e}}function i(a,b,c){var d=a[null==b?a.length-1:b-1];if(d&&"ContentStatement"===d.type&&(c||!d.leftStripped)){var e=d.value;return d.value=d.value.replace(c?/\s+$/:/[ \t]+$/,""),d.leftStripped=d.value!==e,d.leftStripped}}b.__esModule=!0;var j=c(23),k=d(j);e.prototype=new k["default"],e.prototype.Program=function(a){var b=!this.options.ignoreStandalone,c=!this.isRootSeen;this.isRootSeen=!0;for(var d=a.body,e=0,j=d.length;e0)throw new q["default"]("Invalid path: "+d,{loc:c});".."===i&&f++}}return{type:"PathExpression",data:a,depth:f,parts:e,original:d,loc:c}}function k(a,b,c,d,e,f){var g=d.charAt(3)||d.charAt(2),h="{"!==g&&"&"!==g,i=/\*/.test(d);return{type:i?"Decorator":"MustacheStatement",path:a,params:b,hash:c,escaped:h,strip:e,loc:this.locInfo(f)}}function l(a,b,c,d){e(a,c),d=this.locInfo(d);var f={type:"Program",body:b,strip:{},loc:d};return{type:"BlockStatement",path:a.path,params:a.params,hash:a.hash,program:f,openStrip:{},inverseStrip:{},closeStrip:{},loc:d}}function m(a,b,c,d,f,g){d&&d.path&&e(a,d);var h=/\*/.test(a.open);b.blockParams=a.blockParams;var i=void 0,j=void 0;if(c){if(h)throw new q["default"]("Unexpected inverse block on decorator",c);c.chain&&(c.program.body[0].closeStrip=d.strip),j=c.strip,i=c.program}return f&&(f=i,i=b,b=f),{type:h?"DecoratorBlock":"BlockStatement",path:a.path,params:a.params,hash:a.hash,program:b,inverse:i,openStrip:a.strip,inverseStrip:j,closeStrip:d&&d.strip,loc:this.locInfo(g)}}function n(a,b){if(!b&&a.length){var c=a[0].loc,d=a[a.length-1].loc;c&&d&&(b={source:c.source,start:{line:c.start.line,column:c.start.column},end:{line:d.end.line,column:d.end.column}})}return{type:"Program",body:a,strip:{},loc:b}}function o(a,b,c,d){return e(a,c),{type:"PartialBlockStatement",name:a.path,params:a.params,hash:a.hash,program:b,openStrip:a.strip,closeStrip:c&&c.strip,loc:this.locInfo(d)}}b.__esModule=!0,b.SourceLocation=f,b.id=g,b.stripFlags=h,b.stripComment=i,b.preparePath=j,b.prepareMustache=k,b.prepareRawBlock=l,b.prepareBlock=m,b.prepareProgram=n,b.preparePartialBlock=o;var p=c(4),q=d(p)},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(){}function f(a,b,c){void 0===b&&(b={}),h(a,b);var d=i(a,b,c);return(new c.JavaScriptCompiler).compile(d,b)}function g(a,b,c){function d(){var d=i(a,b,c),e=(new c.JavaScriptCompiler).compile(d,b,void 0,!0);return c.template(e)}void 0===b&&(b={}),b=n.extend({},b),h(a,b);var e=void 0;return function(a,b){return e||(e=d()),e.call(this,a,b)}}function h(a,b){if(null==a||"string"!=typeof a&&"Program"!==a.type)throw new m["default"]("You must pass a string or Handlebars AST to Handlebars.compile. You passed "+a);if(b.trackIds||b.stringParams)throw new m["default"]("TrackIds and stringParams are no longer supported. See Github #1145");"data"in b||(b.data=!0),b.compat&&(b.useDepths=!0)}function i(a,b,c){var d=c.parse(a,b);return(new c.Compiler).compile(d,b)}function j(a,b){if(a===b)return!0;if(n.isArray(a)&&n.isArray(b)&&a.length===b.length){for(var c=0;c1)throw new m["default"]("Unsupported number of partial arguments: "+c.length,a);c.length||(this.options.explicitPartialContext?this.opcode("pushLiteral","undefined"):c.push({type:"PathExpression",parts:[],depth:0}));var d=a.name.original,e="SubExpression"===a.name.type;e&&this.accept(a.name),this.setupFullMustacheParams(a,b,void 0,!0);var f=a.indent||"";this.options.preventIndent&&f&&(this.opcode("appendContent",f),f=""),this.opcode("invokePartial",e,d,f),this.opcode("append")},PartialBlockStatement:function(a){this.PartialStatement(a)},MustacheStatement:function(a){this.SubExpression(a),a.escaped&&!this.options.noEscape?this.opcode("appendEscaped"):this.opcode("append")},Decorator:function(a){this.DecoratorBlock(a)},ContentStatement:function(a){a.value&&this.opcode("appendContent",a.value)},CommentStatement:function(){},SubExpression:function(a){k(a);var b=this.classifySexpr(a);"simple"===b?this.simpleSexpr(a):"helper"===b?this.helperSexpr(a):this.ambiguousSexpr(a)},ambiguousSexpr:function(a,b,c){var d=a.path,e=d.parts[0],f=null!=b||null!=c;this.opcode("getContext",d.depth),this.opcode("pushProgram",b),this.opcode("pushProgram",c),d.strict=!0,this.accept(d),this.opcode("invokeAmbiguous",e,f)},simpleSexpr:function(a){var b=a.path;b.strict=!0,this.accept(b),this.opcode("resolvePossibleLambda")},helperSexpr:function(a,b,c){var d=this.setupFullMustacheParams(a,b,c),e=a.path,f=e.parts[0];if(this.options.knownHelpers[f])this.opcode("invokeKnownHelper",d.length,f);else{if(this.options.knownHelpersOnly)throw new m["default"]("You specified knownHelpersOnly, but used the unknown helper "+f,a);e.strict=!0,e.falsy=!0,this.accept(e),this.opcode("invokeHelper",d.length,e.original,p["default"].helpers.simpleId(e))}},PathExpression:function(a){this.addDepth(a.depth),this.opcode("getContext",a.depth);var b=a.parts[0],c=p["default"].helpers.scopedId(a),d=!a.depth&&!c&&this.blockParamIndex(b);d?this.opcode("lookupBlockParam",d,a.parts):b?a.data?(this.options.data=!0,this.opcode("lookupData",a.depth,a.parts,a.strict)):this.opcode("lookupOnContext",a.parts,a.falsy,a.strict,c):this.opcode("pushContext")},StringLiteral:function(a){this.opcode("pushString",a.value)},NumberLiteral:function(a){this.opcode("pushLiteral",a.value)},BooleanLiteral:function(a){this.opcode("pushLiteral",a.value)},UndefinedLiteral:function(){this.opcode("pushLiteral","undefined")},NullLiteral:function(){this.opcode("pushLiteral","null")},Hash:function(a){var b=a.pairs,c=0,d=b.length;for(this.opcode("pushHash");c=0)return[b,e]}}}},function(a,b,c){"use strict";function d(a){return a&&a.__esModule?a:{"default":a}}function e(a){this.value=a}function f(){}function g(a,b,c,d){var e=b.popStack(),f=0,g=c.length;for(a&&g--;f0&&(b+=", "+c.join(", "));var d=0;for(var e in this.aliases){var f=this.aliases[e];this.aliases.hasOwnProperty(e)&&f.children&&f.referenceCount>1&&(b+=", alias"+ ++d+"="+e,f.children[0]="alias"+d)}var g=["container","depth0","helpers","partials","data"];(this.useBlockParams||this.useDepths)&&g.push("blockParams"),this.useDepths&&g.push("depths");var h=this.mergeSource(b);return a?(g.push(h),Function.apply(this,g)):this.source.wrap(["function(",g.join(","),") {\n ",h,"}"])},mergeSource:function(a){var b=this.environment.isSimple,c=!this.forceBuffer,d=void 0,e=void 0,f=void 0,g=void 0;return this.source.each(function(a){a.appendToBuffer?(f?a.prepend(" + "):f=a,g=a):(f&&(e?f.prepend("buffer += "):d=!0,g.add(";"),f=g=void 0),e=!0,b||(c=!1))}),c?f?(f.prepend("return "),g.add(";")):e||this.source.push('return "";'):(a+=", buffer = "+(d?"":this.initializeBuffer()),f?(f.prepend("return buffer + "),g.add(";")):this.source.push("return buffer;")),a&&this.source.prepend("var "+a.substring(2)+(d?"":";\n")),this.source.merge()},blockValue:function(a){var b=this.aliasable("helpers.blockHelperMissing"),c=[this.contextName(0)];this.setupHelperArgs(a,0,c);var d=this.popStack();c.splice(1,0,d),this.push(this.source.functionCall(b,"call",c))},ambiguousBlockValue:function(){var a=this.aliasable("helpers.blockHelperMissing"),b=[this.contextName(0)];this.setupHelperArgs("",0,b,!0),this.flushInline();var c=this.topStack();b.splice(1,0,c),this.pushSource(["if (!",this.lastHelper,") { ",c," = ",this.source.functionCall(a,"call",b),"}"])},appendContent:function(a){this.pendingContent?a=this.pendingContent+a:this.pendingLocation=this.source.currentLocation,this.pendingContent=a},append:function(){if(this.isInline())this.replaceStack(function(a){return[" != null ? ",a,' : ""']}),this.pushSource(this.appendToBuffer(this.popStack()));else{var a=this.popStack();this.pushSource(["if (",a," != null) { ",this.appendToBuffer(a,void 0,!0)," }"]),this.environment.isSimple&&this.pushSource(["else { ",this.appendToBuffer("''",void 0,!0)," }"])}},appendEscaped:function(){this.pushSource(this.appendToBuffer([this.aliasable("container.escapeExpression"),"(",this.popStack(),")"]))},getContext:function(a){this.lastContext=a},pushContext:function(){this.pushStackLiteral(this.contextName(this.lastContext))},lookupOnContext:function(a,b,c,d){var e=0;d||!this.options.compat||this.lastContext?this.pushContext():this.push(this.depthedLookup(a[e++])),this.resolvePath("context",a,e,b,c)},lookupBlockParam:function(a,b){this.useBlockParams=!0,this.push(["blockParams[",a[0],"][",a[1],"]"]),this.resolvePath("context",b,1)},lookupData:function(a,b,c){a?this.pushStackLiteral("container.data(data, "+a+")"):this.pushStackLiteral("data"),this.resolvePath("data",b,0,!0,c)},resolvePath:function(a,b,c,d,e){var f=this;if(this.options.strict||this.options.assumeObjects)return void this.push(g(this.options.strict&&e,this,b,a));for(var h=b.length;cthis.stackVars.length&&this.stackVars.push("stack"+this.stackSlot),this.topStackName()},topStackName:function(){return"stack"+this.stackSlot},flushInline:function(){var a=this.inlineStack;this.inlineStack=[];for(var b=0,c=a.length;b css_rules.length + 5) { return false; } + if(css_rules[j].selectorText && css_rules[j].selectorText.toLowerCase() == rule_name) { + if(delete_flag === true) { + if(sheet.removeRule) { sheet.removeRule(j); } + if(sheet.deleteRule) { sheet.deleteRule(j); } + return true; + } + else { return css_rules[j]; } + } + } + while (css_rules[++j]); + return false; + }, + add_css : function(rule_name, sheet) { + if($.jstree.css.get_css(rule_name, false, sheet)) { return false; } + if(sheet.insertRule) { sheet.insertRule(rule_name + ' { }', 0); } else { sheet.addRule(rule_name, null, 0); } + return $.vakata.css.get_css(rule_name); + }, + remove_css : function(rule_name, sheet) { + return $.vakata.css.get_css(rule_name, true, sheet); + }, + add_sheet : function(opts) { + var tmp = false, is_new = true; + if(opts.str) { + if(opts.title) { tmp = $("style[id='" + opts.title + "-stylesheet']")[0]; } + if(tmp) { is_new = false; } + else { + tmp = document.createElement("style"); + tmp.setAttribute('type',"text/css"); + if(opts.title) { tmp.setAttribute("id", opts.title + "-stylesheet"); } + } + if(tmp.styleSheet) { + if(is_new) { + document.getElementsByTagName("head")[0].appendChild(tmp); + tmp.styleSheet.cssText = opts.str; + } + else { + tmp.styleSheet.cssText = tmp.styleSheet.cssText + " " + opts.str; + } + } + else { + tmp.appendChild(document.createTextNode(opts.str)); + document.getElementsByTagName("head")[0].appendChild(tmp); + } + return tmp.sheet || tmp.styleSheet; + } + if(opts.url) { + if(document.createStyleSheet) { + try { tmp = document.createStyleSheet(opts.url); } catch (e) { } + } + else { + tmp = document.createElement('link'); + tmp.rel = 'stylesheet'; + tmp.type = 'text/css'; + tmp.media = "all"; + tmp.href = opts.url; + document.getElementsByTagName("head")[0].appendChild(tmp); + return tmp.styleSheet; + } + } + } + }; + + // private variables + var instances = [], // instance array (used by $.jstree.reference/create/focused) + focused_instance = -1, // the index in the instance array of the currently focused instance + plugins = {}, // list of included plugins + prepared_move = {}; // for the move_node function + + // jQuery plugin wrapper (thanks to jquery UI widget function) + $.fn.jstree = function (settings) { + var isMethodCall = (typeof settings == 'string'), // is this a method call like $().jstree("open_node") + args = Array.prototype.slice.call(arguments, 1), + returnValue = this; + + // if a method call execute the method on all selected instances + if(isMethodCall) { + if(settings.substring(0, 1) == '_') { return returnValue; } + this.each(function() { + var instance = instances[$.data(this, "jstree_instance_id")], + methodValue = (instance && $.isFunction(instance[settings])) ? instance[settings].apply(instance, args) : instance; + if(typeof methodValue !== "undefined" && (settings.indexOf("is_") === 0 || (methodValue !== true && methodValue !== false))) { returnValue = methodValue; return false; } + }); + } + else { + this.each(function() { + // extend settings and allow for multiple hashes and $.data + var instance_id = $.data(this, "jstree_instance_id"), + a = [], + b = settings ? $.extend({}, true, settings) : {}, + c = $(this), + s = false, + t = []; + a = a.concat(args); + if(c.data("jstree")) { a.push(c.data("jstree")); } + b = a.length ? $.extend.apply(null, [true, b].concat(a)) : b; + + // if an instance already exists, destroy it first + if(typeof instance_id !== "undefined" && instances[instance_id]) { instances[instance_id].destroy(); } + // push a new empty object to the instances array + instance_id = parseInt(instances.push({}),10) - 1; + // store the jstree instance id to the container element + $.data(this, "jstree_instance_id", instance_id); + // clean up all plugins + b.plugins = $.isArray(b.plugins) ? b.plugins : $.jstree.defaults.plugins.slice(); + b.plugins.unshift("core"); + // only unique plugins + b.plugins = b.plugins.sort().join(",,").replace(/(,|^)([^,]+)(,,\2)+(,|$)/g,"$1$2$4").replace(/,,+/g,",").replace(/,$/,"").split(","); + + // extend defaults with passed data + s = $.extend(true, {}, $.jstree.defaults, b); + s.plugins = b.plugins; + $.each(plugins, function (i, val) { + if($.inArray(i, s.plugins) === -1) { s[i] = null; delete s[i]; } + else { t.push(i); } + }); + s.plugins = t; + + // push the new object to the instances array (at the same time set the default classes to the container) and init + instances[instance_id] = new $.jstree._instance(instance_id, $(this).addClass("jstree jstree-" + instance_id), s); + // init all activated plugins for this instance + $.each(instances[instance_id]._get_settings().plugins, function (i, val) { instances[instance_id].data[val] = {}; }); + $.each(instances[instance_id]._get_settings().plugins, function (i, val) { if(plugins[val]) { plugins[val].__init.apply(instances[instance_id]); } }); + // initialize the instance + setTimeout(function() { if(instances[instance_id]) { instances[instance_id].init(); } }, 0); + }); + } + // return the jquery selection (or if it was a method call that returned a value - the returned value) + return returnValue; + }; + // object to store exposed functions and objects + $.jstree = { + defaults : { + plugins : [] + }, + _focused : function () { return instances[focused_instance] || null; }, + _reference : function (needle) { + // get by instance id + if(instances[needle]) { return instances[needle]; } + // get by DOM (if still no luck - return null + var o = $(needle); + if(!o.length && typeof needle === "string") { o = $("#" + needle); } + if(!o.length) { return null; } + return instances[o.closest(".jstree").data("jstree_instance_id")] || null; + }, + _instance : function (index, container, settings) { + // for plugins to store data in + this.data = { core : {} }; + this.get_settings = function () { return $.extend(true, {}, settings); }; + this._get_settings = function () { return settings; }; + this.get_index = function () { return index; }; + this.get_container = function () { return container; }; + this.get_container_ul = function () { return container.children("ul:eq(0)"); }; + this._set_settings = function (s) { + settings = $.extend(true, {}, settings, s); + }; + }, + _fn : { }, + plugin : function (pname, pdata) { + pdata = $.extend({}, { + __init : $.noop, + __destroy : $.noop, + _fn : {}, + defaults : false + }, pdata); + plugins[pname] = pdata; + + $.jstree.defaults[pname] = pdata.defaults; + $.each(pdata._fn, function (i, val) { + val.plugin = pname; + val.old = $.jstree._fn[i]; + $.jstree._fn[i] = function () { + var rslt, + func = val, + args = Array.prototype.slice.call(arguments), + evnt = new $.Event("before.jstree"), + rlbk = false; + + if(this.data.core.locked === true && i !== "unlock" && i !== "is_locked") { return; } + + // Check if function belongs to the included plugins of this instance + do { + if(func && func.plugin && $.inArray(func.plugin, this._get_settings().plugins) !== -1) { break; } + func = func.old; + } while(func); + if(!func) { return; } + + // context and function to trigger events, then finally call the function + if(i.indexOf("_") === 0) { + rslt = func.apply(this, args); + } + else { + rslt = this.get_container().triggerHandler(evnt, { "func" : i, "inst" : this, "args" : args, "plugin" : func.plugin }); + if(rslt === false) { return; } + if(typeof rslt !== "undefined") { args = rslt; } + + rslt = func.apply( + $.extend({}, this, { + __callback : function (data) { + this.get_container().triggerHandler( i + '.jstree', { "inst" : this, "args" : args, "rslt" : data, "rlbk" : rlbk }); + }, + __rollback : function () { + rlbk = this.get_rollback(); + return rlbk; + }, + __call_old : function (replace_arguments) { + return func.old.apply(this, (replace_arguments ? Array.prototype.slice.call(arguments, 1) : args ) ); + } + }), args); + } + + // return the result + return rslt; + }; + $.jstree._fn[i].old = val.old; + $.jstree._fn[i].plugin = pname; + }); + }, + rollback : function (rb) { + if(rb) { + if(!$.isArray(rb)) { rb = [ rb ]; } + $.each(rb, function (i, val) { + instances[val.i].set_rollback(val.h, val.d); + }); + } + } + }; + // set the prototype for all instances + $.jstree._fn = $.jstree._instance.prototype = {}; + + // load the css when DOM is ready + $(function() { + // code is copied from jQuery ($.browser is deprecated + there is a bug in IE) + var u = navigator.userAgent.toLowerCase(), + v = (u.match( /.+?(?:rv|it|ra|ie)[\/: ]([\d.]+)/ ) || [0,'0'])[1], + css_string = '' + + '.jstree ul, .jstree li { display:block; margin:0 0 0 0; padding:0 0 0 0; list-style-type:none; } ' + + '.jstree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } ' + + '.jstree-rtl li { margin-left:0; margin-right:18px; } ' + + '.jstree > ul > li { margin-left:0px; } ' + + '.jstree-rtl > ul > li { margin-right:0px; } ' + + '.jstree ins { display:inline-block; text-decoration:none; width:18px; height:18px; margin:0 0 0 0; padding:0; } ' + + '.jstree a { display:inline-block; line-height:16px; height:16px; color:black; white-space:nowrap; text-decoration:none; padding:1px 2px; margin:0; } ' + + '.jstree a:focus { outline: none; } ' + + '.jstree a > ins { height:16px; width:16px; } ' + + '.jstree a > .jstree-icon { margin-right:3px; } ' + + '.jstree-rtl a > .jstree-icon { margin-left:3px; margin-right:0; } ' + + 'li.jstree-open > ul { display:block; } ' + + 'li.jstree-closed > ul { display:none; } '; + // Correct IE 6 (does not support the > CSS selector) + if(/msie/.test(u) && parseInt(v, 10) == 6) { + is_ie6 = true; + + // fix image flicker and lack of caching + try { + document.execCommand("BackgroundImageCache", false, true); + } catch (err) { } + + css_string += '' + + '.jstree li { height:18px; margin-left:0; margin-right:0; } ' + + '.jstree li li { margin-left:18px; } ' + + '.jstree-rtl li li { margin-left:0px; margin-right:18px; } ' + + 'li.jstree-open ul { display:block; } ' + + 'li.jstree-closed ul { display:none !important; } ' + + '.jstree li a { display:inline; border-width:0 !important; padding:0px 2px !important; } ' + + '.jstree li a ins { height:16px; width:16px; margin-right:3px; } ' + + '.jstree-rtl li a ins { margin-right:0px; margin-left:3px; } '; + } + // Correct IE 7 (shifts anchor nodes onhover) + if(/msie/.test(u) && parseInt(v, 10) == 7) { + is_ie7 = true; + css_string += '.jstree li a { border-width:0 !important; padding:0px 2px !important; } '; + } + // correct ff2 lack of display:inline-block + if(!/compatible/.test(u) && /mozilla/.test(u) && parseFloat(v, 10) < 1.9) { + is_ff2 = true; + css_string += '' + + '.jstree ins { display:-moz-inline-box; } ' + + '.jstree li { line-height:12px; } ' + // WHY?? + '.jstree a { display:-moz-inline-box; } ' + + '.jstree .jstree-no-icons .jstree-checkbox { display:-moz-inline-stack !important; } '; + /* this shouldn't be here as it is theme specific */ + } + // the default stylesheet + $.vakata.css.add_sheet({ str : css_string, title : "jstree" }); + }); + + // core functions (open, close, create, update, delete) + $.jstree.plugin("core", { + __init : function () { + this.data.core.locked = false; + this.data.core.to_open = this.get_settings().core.initially_open; + this.data.core.to_load = this.get_settings().core.initially_load; + }, + defaults : { + html_titles : false, + animation : 500, + initially_open : [], + initially_load : [], + open_parents : true, + notify_plugins : true, + rtl : false, + load_open : false, + strings : { + loading : "Loading ...", + new_node : "New node", + multiple_selection : "Multiple selection" + } + }, + _fn : { + init : function () { + this.set_focus(); + if(this._get_settings().core.rtl) { + this.get_container().addClass("jstree-rtl").css("direction", "rtl"); + } + this.get_container().html(""); + this.data.core.li_height = this.get_container_ul().find("li.jstree-closed, li.jstree-leaf").eq(0).height() || 18; + + this.get_container() + .delegate("li > ins", "click.jstree", $.proxy(function (event) { + var trgt = $(event.target); + // if(trgt.is("ins") && event.pageY - trgt.offset().top < this.data.core.li_height) { this.toggle_node(trgt); } + this.toggle_node(trgt); + }, this)) + .bind("mousedown.jstree", $.proxy(function () { + this.set_focus(); // This used to be setTimeout(set_focus,0) - why? + }, this)) + .bind("dblclick.jstree", function (event) { + var sel; + if(document.selection && document.selection.empty) { document.selection.empty(); } + else { + if(window.getSelection) { + sel = window.getSelection(); + try { + sel.removeAllRanges(); + sel.collapse(); + } catch (err) { } + } + } + }); + if(this._get_settings().core.notify_plugins) { + this.get_container() + .bind("load_node.jstree", $.proxy(function (e, data) { + var o = this._get_node(data.rslt.obj), + t = this; + if(o === -1) { o = this.get_container_ul(); } + if(!o.length) { return; } + o.find("li").each(function () { + var th = $(this); + if(th.data("jstree")) { + $.each(th.data("jstree"), function (plugin, values) { + if(t.data[plugin] && $.isFunction(t["_" + plugin + "_notify"])) { + t["_" + plugin + "_notify"].call(t, th, values); + } + }); + } + }); + }, this)); + } + if(this._get_settings().core.load_open) { + this.get_container() + .bind("load_node.jstree", $.proxy(function (e, data) { + var o = this._get_node(data.rslt.obj), + t = this; + if(o === -1) { o = this.get_container_ul(); } + if(!o.length) { return; } + o.find("li.jstree-open:not(:has(ul))").each(function () { + t.load_node(this, $.noop, $.noop); + }); + }, this)); + } + this.__callback(); + this.load_node(-1, function () { this.loaded(); this.reload_nodes(); }); + }, + destroy : function () { + var i, + n = this.get_index(), + s = this._get_settings(), + _this = this; + + $.each(s.plugins, function (i, val) { + try { plugins[val].__destroy.apply(_this); } catch(err) { } + }); + this.__callback(); + // set focus to another instance if this one is focused + if(this.is_focused()) { + for(i in instances) { + if(instances.hasOwnProperty(i) && i != n) { + instances[i].set_focus(); + break; + } + } + } + // if no other instance found + if(n === focused_instance) { focused_instance = -1; } + // remove all traces of jstree in the DOM (only the ones set using jstree*) and cleans all events + this.get_container() + .unbind(".jstree") + .undelegate(".jstree") + .removeData("jstree_instance_id") + .find("[class^='jstree']") + .addBack() + .attr("class", function () { return this.className.replace(/jstree[^ ]*|$/ig,''); }); + $(document) + .unbind(".jstree-" + n) + .undelegate(".jstree-" + n); + // remove the actual data + instances[n] = null; + delete instances[n]; + }, + + _core_notify : function (n, data) { + if(data.opened) { + this.open_node(n, false, true); + } + }, + + lock : function () { + this.data.core.locked = true; + this.get_container().children("ul").addClass("jstree-locked").css("opacity","0.7"); + this.__callback({}); + }, + unlock : function () { + this.data.core.locked = false; + this.get_container().children("ul").removeClass("jstree-locked").css("opacity","1"); + this.__callback({}); + }, + is_locked : function () { return this.data.core.locked; }, + save_opened : function () { + var _this = this; + this.data.core.to_open = []; + this.get_container_ul().find("li.jstree-open").each(function () { + if(this.id) { _this.data.core.to_open.push("#" + this.id.toString().replace(/^#/,"").replace(/\\\//g,"/").replace(/\//g,"\\\/").replace(/\\\./g,".").replace(/\./g,"\\.").replace(/\:/g,"\\:")); } + }); + this.__callback(_this.data.core.to_open); + }, + save_loaded : function () { }, + reload_nodes : function (is_callback) { + var _this = this, + done = true, + current = [], + remaining = []; + if(!is_callback) { + this.data.core.reopen = false; + this.data.core.refreshing = true; + this.data.core.to_open = $.map($.makeArray(this.data.core.to_open), function (n) { return "#" + n.toString().replace(/^#/,"").replace(/\\\//g,"/").replace(/\//g,"\\\/").replace(/\\\./g,".").replace(/\./g,"\\.").replace(/\:/g,"\\:"); }); + this.data.core.to_load = $.map($.makeArray(this.data.core.to_load), function (n) { return "#" + n.toString().replace(/^#/,"").replace(/\\\//g,"/").replace(/\//g,"\\\/").replace(/\\\./g,".").replace(/\./g,"\\.").replace(/\:/g,"\\:"); }); + if(this.data.core.to_open.length) { + this.data.core.to_load = this.data.core.to_load.concat(this.data.core.to_open); + } + } + if(this.data.core.to_load.length) { + $.each(this.data.core.to_load, function (i, val) { + if(val == "#") { return true; } + if($(val).length) { current.push(val); } + else { remaining.push(val); } + }); + if(current.length) { + this.data.core.to_load = remaining; + $.each(current, function (i, val) { + if(!_this._is_loaded(val)) { + _this.load_node(val, function () { _this.reload_nodes(true); }, function () { _this.reload_nodes(true); }); + done = false; + } + }); + } + } + if(this.data.core.to_open.length) { + $.each(this.data.core.to_open, function (i, val) { + _this.open_node(val, false, true); + }); + } + if(done) { + // TODO: find a more elegant approach to syncronizing returning requests + if(this.data.core.reopen) { clearTimeout(this.data.core.reopen); } + this.data.core.reopen = setTimeout(function () { _this.__callback({}, _this); }, 50); + this.data.core.refreshing = false; + this.reopen(); + } + }, + reopen : function () { + var _this = this; + if(this.data.core.to_open.length) { + $.each(this.data.core.to_open, function (i, val) { + _this.open_node(val, false, true); + }); + } + this.__callback({}); + }, + refresh : function (obj) { + var _this = this; + this.save_opened(); + if(!obj) { obj = -1; } + obj = this._get_node(obj); + if(!obj) { obj = -1; } + if(obj !== -1) { obj.children("UL").remove(); } + else { this.get_container_ul().empty(); } + this.load_node(obj, function () { _this.__callback({ "obj" : obj}); _this.reload_nodes(); }); + }, + // Dummy function to fire after the first load (so that there is a jstree.loaded event) + loaded : function () { + this.__callback(); + }, + // deal with focus + set_focus : function () { + if(this.is_focused()) { return; } + var f = $.jstree._focused(); + if(f) { f.unset_focus(); } + + this.get_container().addClass("jstree-focused"); + focused_instance = this.get_index(); + this.__callback(); + }, + is_focused : function () { + return focused_instance == this.get_index(); + }, + unset_focus : function () { + if(this.is_focused()) { + this.get_container().removeClass("jstree-focused"); + focused_instance = -1; + } + this.__callback(); + }, + + // traverse + _get_node : function (obj) { + var $obj = $(obj, this.get_container()); + if($obj.is(".jstree") || obj == -1) { return -1; } + $obj = $obj.closest("li", this.get_container()); + return $obj.length ? $obj : false; + }, + _get_next : function (obj, strict) { + obj = this._get_node(obj); + if(obj === -1) { return this.get_container().find("> ul > li:first-child"); } + if(!obj.length) { return false; } + if(strict) { return (obj.nextAll("li").size() > 0) ? obj.nextAll("li:eq(0)") : false; } + + if(obj.hasClass("jstree-open")) { return obj.find("li:eq(0)"); } + else if(obj.nextAll("li").size() > 0) { return obj.nextAll("li:eq(0)"); } + else { return obj.parentsUntil(".jstree","li").next("li").eq(0); } + }, + _get_prev : function (obj, strict) { + obj = this._get_node(obj); + if(obj === -1) { return this.get_container().find("> ul > li:last-child"); } + if(!obj.length) { return false; } + if(strict) { return (obj.prevAll("li").length > 0) ? obj.prevAll("li:eq(0)") : false; } + + if(obj.prev("li").length) { + obj = obj.prev("li").eq(0); + while(obj.hasClass("jstree-open")) { obj = obj.children("ul:eq(0)").children("li:last"); } + return obj; + } + else { var o = obj.parentsUntil(".jstree","li:eq(0)"); return o.length ? o : false; } + }, + _get_parent : function (obj) { + obj = this._get_node(obj); + if(obj == -1 || !obj.length) { return false; } + var o = obj.parentsUntil(".jstree", "li:eq(0)"); + return o.length ? o : -1; + }, + _get_children : function (obj) { + obj = this._get_node(obj); + if(obj === -1) { return this.get_container().children("ul:eq(0)").children("li"); } + if(!obj.length) { return false; } + return obj.children("ul:eq(0)").children("li"); + }, + get_path : function (obj, id_mode) { + var p = [], + _this = this; + obj = this._get_node(obj); + if(obj === -1 || !obj || !obj.length) { return false; } + obj.parentsUntil(".jstree", "li").each(function () { + p.push( id_mode ? this.id : _this.get_text(this) ); + }); + p.reverse(); + p.push( id_mode ? obj.attr("id") : this.get_text(obj) ); + return p; + }, + + // string functions + _get_string : function (key) { + return this._get_settings().core.strings[key] || key; + }, + + is_open : function (obj) { obj = this._get_node(obj); return obj && obj !== -1 && obj.hasClass("jstree-open"); }, + is_closed : function (obj) { obj = this._get_node(obj); return obj && obj !== -1 && obj.hasClass("jstree-closed"); }, + is_leaf : function (obj) { obj = this._get_node(obj); return obj && obj !== -1 && obj.hasClass("jstree-leaf"); }, + correct_state : function (obj) { + obj = this._get_node(obj); + if(!obj || obj === -1) { return false; } + obj.removeClass("jstree-closed jstree-open").addClass("jstree-leaf").children("ul").remove(); + this.__callback({ "obj" : obj }); + }, + // open/close + open_node : function (obj, callback, skip_animation) { + obj = this._get_node(obj); + if(!obj.length) { return false; } + if(!obj.hasClass("jstree-closed")) { if(callback) { callback.call(); } return false; } + var s = skip_animation || is_ie6 ? 0 : this._get_settings().core.animation, + t = this; + if(!this._is_loaded(obj)) { + obj.children("a").addClass("jstree-loading"); + this.load_node(obj, function () { t.open_node(obj, callback, skip_animation); }, callback); + } + else { + if(this._get_settings().core.open_parents) { + obj.parentsUntil(".jstree",".jstree-closed").each(function () { + t.open_node(this, false, true); + }); + } + if(s) { obj.children("ul").css("display","none"); } + obj.removeClass("jstree-closed").addClass("jstree-open").children("a").removeClass("jstree-loading"); + if(s) { obj.children("ul").stop(true, true).slideDown(s, function () { this.style.display = ""; t.after_open(obj); }); } + else { t.after_open(obj); } + this.__callback({ "obj" : obj }); + if(callback) { callback.call(); } + } + }, + after_open : function (obj) { this.__callback({ "obj" : obj }); }, + close_node : function (obj, skip_animation) { + obj = this._get_node(obj); + var s = skip_animation || is_ie6 ? 0 : this._get_settings().core.animation, + t = this; + if(!obj.length || !obj.hasClass("jstree-open")) { return false; } + if(s) { obj.children("ul").attr("style","display:block !important"); } + obj.removeClass("jstree-open").addClass("jstree-closed"); + if(s) { obj.children("ul").stop(true, true).slideUp(s, function () { this.style.display = ""; t.after_close(obj); }); } + else { t.after_close(obj); } + this.__callback({ "obj" : obj }); + }, + after_close : function (obj) { this.__callback({ "obj" : obj }); }, + toggle_node : function (obj) { + obj = this._get_node(obj); + if(obj.hasClass("jstree-closed")) { return this.open_node(obj); } + if(obj.hasClass("jstree-open")) { return this.close_node(obj); } + }, + open_all : function (obj, do_animation, original_obj) { + obj = obj ? this._get_node(obj) : -1; + if(!obj || obj === -1) { obj = this.get_container_ul(); } + if(original_obj) { + obj = obj.find("li.jstree-closed"); + } + else { + original_obj = obj; + if(obj.is(".jstree-closed")) { obj = obj.find("li.jstree-closed").addBack(); } + else { obj = obj.find("li.jstree-closed"); } + } + var _this = this; + obj.each(function () { + var __this = this; + if(!_this._is_loaded(this)) { _this.open_node(this, function() { _this.open_all(__this, do_animation, original_obj); }, !do_animation); } + else { _this.open_node(this, false, !do_animation); } + }); + // so that callback is fired AFTER all nodes are open + if(original_obj.find('li.jstree-closed').length === 0) { this.__callback({ "obj" : original_obj }); } + }, + close_all : function (obj, do_animation) { + var _this = this; + obj = obj ? this._get_node(obj) : this.get_container(); + if(!obj || obj === -1) { obj = this.get_container_ul(); } + obj.find("li.jstree-open").addBack().each(function () { _this.close_node(this, !do_animation); }); + this.__callback({ "obj" : obj }); + }, + clean_node : function (obj) { + obj = obj && obj != -1 ? $(obj) : this.get_container_ul(); + obj = obj.is("li") ? obj.find("li").addBack() : obj.find("li"); + obj.removeClass("jstree-last") + .filter("li:last-child").addClass("jstree-last").end() + .filter(":has(li)") + .not(".jstree-open").removeClass("jstree-leaf").addClass("jstree-closed"); + obj.not(".jstree-open, .jstree-closed").addClass("jstree-leaf").children("ul").remove(); + this.__callback({ "obj" : obj }); + }, + // rollback + get_rollback : function () { + this.__callback(); + return { i : this.get_index(), h : this.get_container().children("ul").clone(true), d : this.data }; + }, + set_rollback : function (html, data) { + this.get_container().empty().append(html); + this.data = data; + this.__callback(); + }, + // Dummy functions to be overwritten by any datastore plugin included + load_node : function (obj, s_call, e_call) { this.__callback({ "obj" : obj }); }, + _is_loaded : function (obj) { return true; }, + + // Basic operations: create + create_node : function (obj, position, js, callback, is_loaded) { + obj = this._get_node(obj); + position = typeof position === "undefined" ? "last" : position; + var d = $("
    • "), + s = this._get_settings().core, + tmp; + + if(obj !== -1 && !obj.length) { return false; } + if(!is_loaded && !this._is_loaded(obj)) { this.load_node(obj, function () { this.create_node(obj, position, js, callback, true); }); return false; } + + this.__rollback(); + + if(typeof js === "string") { js = { "data" : js }; } + if(!js) { js = {}; } + if(js.attr) { d.attr(js.attr); } + if(js.metadata) { d.data(js.metadata); } + if(js.state) { d.addClass("jstree-" + js.state); } + if(!js.data) { js.data = this._get_string("new_node"); } + if(!$.isArray(js.data)) { tmp = js.data; js.data = []; js.data.push(tmp); } + $.each(js.data, function (i, m) { + tmp = $(""); + if($.isFunction(m)) { m = m.call(this, js); } + if(typeof m == "string") { tmp.attr('href','#')[ s.html_titles ? "html" : "text" ](m); } + else { + if(!m.attr) { m.attr = {}; } + if(!m.attr.href) { m.attr.href = '#'; } + tmp.attr(m.attr)[ s.html_titles ? "html" : "text" ](m.title); + if(m.language) { tmp.addClass(m.language); } + } + tmp.prepend(" "); + if(!m.icon && js.icon) { m.icon = js.icon; } + if(m.icon) { + if(m.icon.indexOf("/") === -1) { tmp.children("ins").addClass(m.icon); } + else { tmp.children("ins").css("background","url('" + m.icon + "') center center no-repeat"); } + } + d.append(tmp); + }); + d.prepend(" "); + if(obj === -1) { + obj = this.get_container(); + if(position === "before") { position = "first"; } + if(position === "after") { position = "last"; } + } + switch(position) { + case "before": obj.before(d); tmp = this._get_parent(obj); break; + case "after" : obj.after(d); tmp = this._get_parent(obj); break; + case "inside": + case "first" : + if(!obj.children("ul").length) { obj.append("