├── .rspec
├── app
├── views
│ └── spree
│ │ ├── api
│ │ └── relations
│ │ │ └── show.v1.rabl
│ │ └── admin
│ │ ├── relations
│ │ ├── destroy.js.erb
│ │ └── create.js.erb
│ │ ├── products
│ │ ├── _related_products.html.erb
│ │ ├── _related_products_table.html.erb
│ │ └── related.html.erb
│ │ └── relation_types
│ │ ├── new.html.erb
│ │ ├── edit.html.erb
│ │ ├── _form.html.erb
│ │ └── index.html.erb
├── controllers
│ └── spree
│ │ ├── admin
│ │ ├── relation_types_controller.rb
│ │ ├── spree_related_products
│ │ │ └── products_controller_decorator.rb
│ │ └── relations_controller.rb
│ │ └── api
│ │ └── relations_controller.rb
├── assets
│ ├── stylesheets
│ │ └── spree
│ │ │ └── related_products.css
│ └── javascripts
│ │ └── spree
│ │ └── frontend
│ │ └── views
│ │ └── spree
│ │ └── product
│ │ └── related.js
├── models
│ ├── spree
│ │ ├── relation_type.rb
│ │ ├── relation.rb
│ │ ├── calculator
│ │ │ └── related_product_discount.rb
│ │ └── product_decorator.rb
│ └── related_products
│ │ └── spree
│ │ └── product_decorator.rb
├── overrides
│ ├── add_product_relation_admin_sub_menu_tab.rb
│ └── add_related_product_admin_tabs.rb
└── helpers
│ └── spree
│ └── related_products_helper.rb
├── .rubocop.yml
├── Gemfile
├── gemfiles
├── spree_4_0.gemfile
├── spree_4_1.gemfile
├── spree_4_2.gemfile
├── spree_3_7.gemfile
└── spree_master.gemfile
├── .gitignore
├── db
└── migrate
│ ├── 20130727004612_add_position_to_spree_relations.rb
│ ├── 20111129044813_prefixing_tables_with_spree.rb
│ ├── 20100324123835_add_discount_to_relation.rb
│ ├── 20210727141500_update_relations_to_bigint.rb
│ ├── 20120208144454_update_relation_types.rb
│ ├── 20100308090631_create_relation_types.rb
│ ├── 20100308092101_create_relations.rb
│ └── 20120623014337_update_relations.rb
├── lib
├── spree_related_products.rb
├── spree_related_products
│ ├── version.rb
│ └── engine.rb
└── generators
│ └── spree_related_products
│ └── install
│ └── install_generator.rb
├── spec
├── support
│ └── shoulda_matchers.rb
├── factories
│ ├── relation_type_factory.rb
│ └── relation_factory.rb
├── spec_helper.rb
├── models
│ └── spree
│ │ ├── relation_spec.rb
│ │ ├── relation_type_spec.rb
│ │ ├── calculator
│ │ └── related_product_discount_spec.rb
│ │ └── product_spec.rb
├── controllers
│ └── spree
│ │ ├── admin
│ │ ├── products_controller_decorator_spec.rb
│ │ └── relations_controller_spec.rb
│ │ └── api
│ │ └── relations_controller_spec.rb
└── features
│ └── spree
│ └── admin
│ ├── product_relation_spec.rb
│ └── relation_types_spec.rb
├── bin
└── rails
├── Rakefile
├── .hound.yml
├── Appraisals
├── config
├── locales
│ ├── pl.yml
│ ├── cs.yml
│ ├── en.yml
│ ├── bg.yml
│ ├── nl.yml
│ ├── sv.yml
│ ├── es.yml
│ ├── ru.yml
│ ├── it.yml
│ ├── fr.yml
│ ├── pt-BR.yml
│ └── de.yml
└── routes.rb
├── Guardfile
├── spree_related_products.gemspec
├── .travis.yml
├── LICENSE.md
├── CONTRIBUTING.md
└── README.md
/.rspec:
--------------------------------------------------------------------------------
1 | --color
2 | -r spec_helper
3 | -f documentation
4 |
--------------------------------------------------------------------------------
/app/views/spree/api/relations/show.v1.rabl:
--------------------------------------------------------------------------------
1 | object @relation
2 | attributes *Spree::Relation.column_names
3 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | ---
2 | inherit_from: .hound.yml
3 |
4 | AllCops:
5 | Exclude:
6 | - spec/dummy/**/*
7 | - bin/*
8 | - Guardfile
9 |
--------------------------------------------------------------------------------
/app/controllers/spree/admin/relation_types_controller.rb:
--------------------------------------------------------------------------------
1 | module Spree
2 | module Admin
3 | class RelationTypesController < ResourceController
4 | end
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/app/views/spree/admin/relations/destroy.js.erb:
--------------------------------------------------------------------------------
1 | $('#products-table-wrapper').html('<%= escape_javascript(render "spree/admin/products/related_products_table", product: @product) %>');
2 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | spree_version = 'master'
4 | gem 'spree', github: 'spree/spree', branch: spree_version
5 | gem 'rails-controller-testing'
6 |
7 | gemspec
8 |
--------------------------------------------------------------------------------
/gemfiles/spree_4_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "spree", "~> 4.0.0"
6 | gem "rails-controller-testing"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/gemfiles/spree_4_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "spree", "~> 4.1.0"
6 | gem "rails-controller-testing"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/gemfiles/spree_4_2.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "spree", "~> 4.2.0.rc4"
6 | gem "rails-controller-testing"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | .DS_Store
3 | .rvmrc
4 | Gemfile.lock
5 | pkg
6 | spec/dummy
7 | *~
8 | tmp
9 | .localeapp
10 | coverage
11 | .ruby-version
12 | .ruby-gemset
13 | .idea
14 | gemfiles/*.gemfile.lock
15 |
--------------------------------------------------------------------------------
/db/migrate/20130727004612_add_position_to_spree_relations.rb:
--------------------------------------------------------------------------------
1 | class AddPositionToSpreeRelations < SpreeExtension::Migration[4.2]
2 | def change
3 | add_column :spree_relations, :position, :integer
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/spree_related_products.rb:
--------------------------------------------------------------------------------
1 | require 'spree_backend'
2 | require 'spree_core'
3 | require 'spree_related_products/engine'
4 | require 'spree_related_products/version'
5 | require 'spree_extension'
6 | require 'deface'
7 |
--------------------------------------------------------------------------------
/spec/support/shoulda_matchers.rb:
--------------------------------------------------------------------------------
1 | require 'shoulda/matchers'
2 |
3 | Shoulda::Matchers.configure do |config|
4 | config.integrate do |with|
5 | with.test_framework :rspec
6 | with.library :rails
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/spec/factories/relation_type_factory.rb:
--------------------------------------------------------------------------------
1 | FactoryBot.define do
2 | factory :relation_type, class: Spree::RelationType do
3 | name { generate(:random_string) }
4 | applies_to { 'Spree::Product' }
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/gemfiles/spree_3_7.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "spree", "~> 3.7.0"
6 | gem "rails-controller-testing"
7 | gem "sass-rails"
8 |
9 | gemspec path: "../"
10 |
--------------------------------------------------------------------------------
/gemfiles/spree_master.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "spree", github: "spree/spree", branch: "master"
6 | gem "rails-controller-testing"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | ENGINE_ROOT = File.expand_path('../..', __FILE__)
4 | ENGINE_PATH = File.expand_path('../../lib/spree_related_products/engine', __FILE__)
5 |
6 | require 'rails/all'
7 | require 'rails/engine/commands'
--------------------------------------------------------------------------------
/app/assets/stylesheets/spree/related_products.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that includes stylesheets for spree_related_products
3 | *= require_self
4 | */
5 |
6 | #related-products .container.position-relative {
7 | max-width: 95%;
8 | }
--------------------------------------------------------------------------------
/db/migrate/20111129044813_prefixing_tables_with_spree.rb:
--------------------------------------------------------------------------------
1 | class PrefixingTablesWithSpree < SpreeExtension::Migration[4.2]
2 | def change
3 | rename_table :relation_types, :spree_relation_types
4 | rename_table :relations, :spree_relations
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/spec/factories/relation_factory.rb:
--------------------------------------------------------------------------------
1 | FactoryBot.define do
2 | factory :relation, class: Spree::Relation do
3 | association :relatable, factory: :product
4 | association :related_to, factory: :product
5 | relation_type { 'Spree::Product' }
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/models/spree/relation_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Spree::RelationType < ActiveRecord::Base
4 | has_many :relations, dependent: :destroy
5 |
6 | validates :name, :applies_to, presence: true
7 | validates :name, uniqueness: { case_sensitive: false }
8 | end
9 |
--------------------------------------------------------------------------------
/app/views/spree/admin/relations/create.js.erb:
--------------------------------------------------------------------------------
1 | $('#products-table-wrapper').html('<%= escape_javascript(render "spree/admin/products/related_products_table", product: @product) %>');
2 | $('#add_product_name').val('');
3 | $('#add_variant_id').val('');
4 | $('#add_quantity').val(1);
5 | $('.actions a').tooltip();
6 |
--------------------------------------------------------------------------------
/lib/spree_related_products/version.rb:
--------------------------------------------------------------------------------
1 | module SpreeRelatedProducts
2 | VERSION = '4.5.0'.freeze
3 |
4 | module_function
5 |
6 | # Returns the version of the currently loaded SpreeRelatedProducts as a
7 | # Gem::Version .
8 | def version
9 | Gem::Version.new VERSION
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20100324123835_add_discount_to_relation.rb:
--------------------------------------------------------------------------------
1 | class AddDiscountToRelation < SpreeExtension::Migration[4.2]
2 | def self.up
3 | add_column :relations, :discount_amount, :decimal, precision: 8, scale: 2, default: 0.0
4 | end
5 |
6 | def self.down
7 | remove_column :relations, :discount_amount
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/models/spree/relation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Spree::Relation < ActiveRecord::Base
4 | belongs_to :relation_type
5 | belongs_to :relatable, polymorphic: true, touch: true
6 | belongs_to :related_to, polymorphic: true
7 |
8 | validates :relation_type, :relatable, :related_to, presence: true
9 | end
10 |
--------------------------------------------------------------------------------
/app/overrides/add_product_relation_admin_sub_menu_tab.rb:
--------------------------------------------------------------------------------
1 | Deface::Override.new(
2 | virtual_path: 'spree/admin/shared/sub_menu/_product',
3 | name: 'add_product_relation_admin_sub_menu_tab',
4 | insert_bottom: '[data-hook="admin_product_sub_tabs"]',
5 | text: '<%= tab :relation_types, label: plural_resource_name(Spree::RelationType) %>'
6 | )
7 |
--------------------------------------------------------------------------------
/db/migrate/20210727141500_update_relations_to_bigint.rb:
--------------------------------------------------------------------------------
1 | class UpdateRelationsToBigint < ActiveRecord::Migration[4.2]
2 | def change
3 | change_table(:spree_relations) do |t|
4 | t.change :relation_type_id, :bigint
5 | t.change :relatable_id, :bigint
6 | t.change :related_to_id, :bigint
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20120208144454_update_relation_types.rb:
--------------------------------------------------------------------------------
1 | class UpdateRelationTypes < SpreeExtension::Migration[4.2]
2 | def up
3 | Spree::RelationType.where(applies_to: 'Product').update_all(applies_to: 'Spree::Product')
4 | end
5 |
6 | def down
7 | Spree::RelationType.where(applies_to: 'Spree::Product').update_all(applies_to: 'Product')
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20100308090631_create_relation_types.rb:
--------------------------------------------------------------------------------
1 | class CreateRelationTypes < SpreeExtension::Migration[4.2]
2 | def self.up
3 | create_table :relation_types do |t|
4 | t.string :name
5 | t.text :description
6 | t.string :applies_to
7 | t.timestamps
8 | end
9 | end
10 |
11 | def self.down
12 | drop_table :relation_types
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler'
2 | Bundler::GemHelper.install_tasks
3 |
4 | require 'rspec/core/rake_task'
5 | require 'spree/testing_support/common_rake'
6 |
7 | RSpec::Core::RakeTask.new
8 |
9 | task default: :spec
10 |
11 | desc 'Generates a dummy app for testing'
12 | task :test_app do
13 | ENV['LIB_NAME'] = 'spree_related_products'
14 | Rake::Task['common:test_app'].invoke
15 | end
16 |
--------------------------------------------------------------------------------
/app/controllers/spree/admin/spree_related_products/products_controller_decorator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Spree::Admin::SpreeRelatedProducts::ProductsControllerDecorator
4 | def related
5 | load_resource
6 | @relation_types = Spree::Product.relation_types
7 | end
8 | end
9 |
10 | Spree::Admin::ProductsController.prepend(Spree::Admin::SpreeRelatedProducts::ProductsControllerDecorator)
11 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # Configure Rails Environment
2 | ENV['RAILS_ENV'] = 'test'
3 |
4 | require File.expand_path('../dummy/config/environment.rb', __FILE__)
5 |
6 | require 'spree_dev_tools/rspec/spec_helper'
7 |
8 | # Requires supporting ruby files with custom matchers and macros, etc,
9 | # in spec/support/ and its subdirectories.
10 | Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].sort.each { |f| require f }
--------------------------------------------------------------------------------
/app/views/spree/admin/products/_related_products.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for(:page_tabs) do %>
2 | <%= content_tag :li, class: 'nav-item' do %>
3 | <%= link_to_with_icon 'resize-small',
4 | Spree.t(:related_products),
5 | related_admin_product_url(@product),
6 | class: "nav-link #{'active' if current == :related_products}" %>
7 |
8 | <% end if can?(:admin, Spree::Product) && !@product.deleted? %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/db/migrate/20100308092101_create_relations.rb:
--------------------------------------------------------------------------------
1 | class CreateRelations < SpreeExtension::Migration[4.2]
2 | def self.up
3 | create_table :relations, force: true do |t|
4 | t.references :relation_type
5 | t.references :relatable, polymorphic: true
6 | t.references :related_to, polymorphic: true
7 | t.timestamps
8 | end
9 | end
10 |
11 | def self.down
12 | drop_table :relations
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/overrides/add_related_product_admin_tabs.rb:
--------------------------------------------------------------------------------
1 | insert_after_selector =
2 | if defined?(Deface::LambdaMatcher)
3 | Deface::LambdaMatcher.new do |document|
4 | [document.xpath(".//erb").last]
5 | end
6 | else
7 | ':last-child' # TODO: this doesn't work correctly
8 | end
9 | Deface::Override.new(
10 | virtual_path: 'spree/admin/shared/_product_tabs',
11 | name: 'add_related_products_admin_tab',
12 | insert_after: insert_after_selector,
13 | partial: 'spree/admin/products/related_products'
14 | )
15 |
--------------------------------------------------------------------------------
/spec/models/spree/relation_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Relation, type: :model do
2 | context 'relations' do
3 | it { is_expected.to belong_to(:relation_type) }
4 | it { is_expected.to belong_to(:relatable) }
5 | it { is_expected.to belong_to(:related_to) }
6 | end
7 |
8 | context 'validation' do
9 | it { is_expected.to validate_presence_of(:relation_type) }
10 | it { is_expected.to validate_presence_of(:relatable) }
11 | it { is_expected.to validate_presence_of(:related_to) }
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/.hound.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # Too picky.
3 | LineLength:
4 | Enabled: false
5 |
6 | # This should truly be on for well documented gems.
7 | Documentation:
8 | Enabled: false
9 |
10 | # Neatly aligned code is too swell.
11 | SpaceBeforeFirstArg:
12 | Enabled: false
13 |
14 | # Don't mess with RSpec DSL.
15 | Blocks:
16 | Exclude:
17 | - 'spec/**/*'
18 |
19 | # It say we should use fail over raise.
20 | SignalException:
21 | Enabled: false
22 |
23 | # Avoid contradictory style rules by enforce single quotes.
24 | StringLiterals:
25 | EnforcedStyle: single_quotes
26 |
--------------------------------------------------------------------------------
/db/migrate/20120623014337_update_relations.rb:
--------------------------------------------------------------------------------
1 | class UpdateRelations < SpreeExtension::Migration[4.2]
2 | def up
3 | Spree::Relation.where(relatable_type: 'Product').update_all(relatable_type: 'Spree::Product')
4 | Spree::Relation.where(related_to_type: 'Product').update_all(related_to_type: 'Spree::Product')
5 | end
6 |
7 | def down
8 | Spree::Relation.where(relatable_type: 'Spree::Product').update_all(relatable_type: 'Product')
9 | Spree::Relation.where(related_to_type: 'Spree::Product').update_all(related_to_type: 'Product')
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise 'spree-3-7' do
2 | gem 'spree', '~> 3.7.0'
3 | gem 'rails-controller-testing'
4 | gem 'sass-rails'
5 | end
6 |
7 | appraise 'spree-4-0' do
8 | gem 'spree', '~> 4.0.0'
9 | gem 'rails-controller-testing'
10 | end
11 |
12 | appraise 'spree-4-1' do
13 | gem 'spree', '~> 4.1.0'
14 | gem 'rails-controller-testing'
15 | end
16 |
17 | appraise 'spree-4-2' do
18 | gem 'spree', '~> 4.2.0.rc4'
19 | gem 'rails-controller-testing'
20 | end
21 |
22 | appraise 'spree-master' do
23 | gem 'spree', github: 'spree/spree', branch: 'master'
24 | gem 'rails-controller-testing'
25 | end
26 |
--------------------------------------------------------------------------------
/config/locales/pl.yml:
--------------------------------------------------------------------------------
1 | ---
2 | pl:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relacja
7 | other: Relacje
8 | spree/relation_type:
9 | one: Typ relacja
10 | other: Typy relacji
11 | spree:
12 | new_relation_type: Nowy typ relacji
13 | related_product_discount: Zniżka na powiązany produkt
14 | applies_to: Ma zastosowanie do
15 | related_products: Produkty powiązane
16 | add_related_product: Dodaj powiązany produkt
17 | name_or_sku_short: Nazwa lub SKU
18 | no_relation_types: Musisz najpierw skonfigurować typy relacji
19 |
--------------------------------------------------------------------------------
/config/locales/cs.yml:
--------------------------------------------------------------------------------
1 | ---
2 | cs:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relation
7 | other: Relations
8 | spree/relation_type:
9 | one: Relation Type
10 | other: Typy vztahů
11 | spree:
12 | new_relation_type: Nový typ relace
13 | related_product_discount: Sleva pro související zboží
14 | applies_to: Platí pro
15 | related_products: Související zboží
16 | add_related_product: Nové související zboží
17 | name_or_sku_short: Název nebo SKU
18 | no_relation_types: 'Musíte nastavit typy vztahů, než budete moci použít tuto funkci.'
19 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | ---
2 | en:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relation
7 | other: Relations
8 | spree/relation_type:
9 | one: Relation Type
10 | other: Relation Types
11 | spree:
12 | new_relation_type: New Relation Type
13 | related_product_discount: Related Product Discount
14 | applies_to: Applies To
15 | related_products: Related Products
16 | add_related_product: Add Related Product
17 | name_or_sku_short: Name or SKU
18 | no_relation_types: You need to configure Relation Types before you can use this feature.
19 |
--------------------------------------------------------------------------------
/config/locales/bg.yml:
--------------------------------------------------------------------------------
1 | ---
2 | bg:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Релация
7 | other: Релации
8 | spree/relation_type:
9 | one: Тип Релация
10 | other: Типове Релации
11 | spree:
12 | new_relation_type: Нов вид релация
13 | related_product_discount: Намаление на свързани продукти
14 | applies_to: Отнася се за
15 | related_products: Свързани продукти
16 | add_related_product: Добави свързан продукт
17 | name_or_sku_short: Име или SKU
18 | no_relation_types: Трябва да се конфигурира Типове Релации преди да може да се използва
19 |
--------------------------------------------------------------------------------
/config/locales/nl.yml:
--------------------------------------------------------------------------------
1 | ---
2 | nl:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relatie
7 | other: Relatie
8 | spree/relation_type:
9 | one: Relatie type
10 | other: Relatie types
11 | spree:
12 | new_relation_type: Nieuw relatie type
13 | related_product_discount: Korting gerelateerd product
14 | applies_to: Toegepast op
15 | related_products: Gerelateerde producten
16 | add_related_product: Gerelateerd product toevoegen
17 | name_or_sku_short: Naam of SKU
18 | no_relation_types: Je moet relatie types instellen voordat je deze functie kan gebruiken.
19 |
--------------------------------------------------------------------------------
/config/locales/sv.yml:
--------------------------------------------------------------------------------
1 | ---
2 | sv:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relation
7 | other: Relationer
8 | spree/relation_type:
9 | one: Relationstyp
10 | other: Relationstyper
11 | spree:
12 | new_relation_type: Ny typ av relation
13 | related_product_discount: Relaterad produkt rabatt
14 | applies_to: Gäller
15 | related_products: Relaterade produkter
16 | add_related_product: Lägg till relaterad produkt
17 | name_or_sku_short: Namn eller SKU
18 | no_relation_types: Du måste konfigurera relationstyper innan du kan använda den här funktionen.
19 |
--------------------------------------------------------------------------------
/config/locales/es.yml:
--------------------------------------------------------------------------------
1 | ---
2 | es:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relación
7 | other: Relaciónes
8 | spree/relation_type:
9 | one: Tipo de relación
10 | other: Tipos de relación
11 | spree:
12 | new_relation_type: Nuevo tipo de relación
13 | related_product_discount: Descuento al producto relacionado
14 | applies_to: Se aplica a
15 | related_products: Productos relacionados
16 | add_related_product: Añadir producto relacionado
17 | name_or_sku_short: Nombre o SKU
18 | no_relation_types: Debes configurar tipos de relación antes de poder usar esto.
19 |
--------------------------------------------------------------------------------
/config/locales/ru.yml:
--------------------------------------------------------------------------------
1 | ---
2 | ru:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Oтношение
7 | other: Oтношения
8 | spree/relation_type:
9 | one: Тип Oтношений
10 | other: Типы Отношений
11 | spree:
12 | new_relation_type: Новый Тип Отношений
13 | related_product_discount: Скидка На Относящийся Товар
14 | applies_to: Применяется К
15 | related_products: Относящиеся Товары
16 | add_related_product: Добавить Относящийся Товар
17 | name_or_sku_short: Название или Артикул
18 | no_relation_types: Перед тем как поспользоваться этим функционалом настройте Типы Отношений.
19 |
--------------------------------------------------------------------------------
/config/locales/it.yml:
--------------------------------------------------------------------------------
1 | ---
2 | it:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relazione
7 | other: Relazioni
8 | spree/relation_type:
9 | one: Tipo di Relazione
10 | other: Tipi di Relazione
11 | spree:
12 | new_relation_type: Nuovo Tipo di Relazione
13 | related_product_discount: Sconto prodotti correlati
14 | applies_to: Si applica a
15 | related_products: Prodotti Correlati
16 | add_related_product: Aggiungi un Prodotto Correlato
17 | name_or_sku_short: Nome o SKU
18 | no_relation_types: Devi configurare i Tipi di Relazione prima di poter usare questa funzione.
19 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Spree::Core::Engine.add_routes do
2 | namespace :admin, path: Spree.admin_path do
3 | resources :relation_types
4 | resources :products, only: [] do
5 | get :related, on: :member
6 | resources :relations do
7 | collection do
8 | post :update_positions
9 | end
10 | end
11 | end
12 | end
13 |
14 | namespace :api, defaults: { format: 'json' } do
15 | resources :products, only: [] do
16 | get :related, on: :member
17 | resources :relations do
18 | collection do
19 | post :update_positions
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/config/locales/fr.yml:
--------------------------------------------------------------------------------
1 | ---
2 | fr:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relation
7 | other: Relations
8 | spree/relation_type:
9 | one: Type de relation
10 | other: Types de relation
11 | spree:
12 | new_relation_type: Nouveau type de relation
13 | related_product_discount: Rabais de produit connexe
14 | applies_to: Appliquer à
15 | related_products: Produits connexes
16 | add_related_product: Ajouter un produit connexe
17 | name_or_sku_short: Nom ou code barre
18 | no_relation_types: Vous devez configurer des types de relation avant de pouvoir utiliser cette fonctionnalité.
--------------------------------------------------------------------------------
/spec/controllers/spree/admin/products_controller_decorator_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Admin::ProductsController, type: :controller do
2 | stub_authorization!
3 |
4 | let(:user) { create(:user) }
5 | let(:product) { create(:product) }
6 |
7 | before { allow(controller).to receive(:spree_current_user).and_return(user) }
8 |
9 | context 'related' do
10 | it 'is not routable' do
11 | get :related, params: { id: product.id }
12 | expect(response.status).to be(200)
13 | end
14 |
15 | it 'responds to model_class as Spree::Relation' do
16 | expect(controller.send(:model_class)).to eq Spree::Product
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/generators/spree_related_products/install/install_generator.rb:
--------------------------------------------------------------------------------
1 | module SpreeRelatedProducts
2 | module Generators
3 | class InstallGenerator < Rails::Generators::Base
4 | class_option :migrate, type: :boolean, default: true, banner: 'Migrate the database'
5 |
6 | def add_migrations
7 | run 'bundle exec rake railties:install:migrations FROM=spree_related_products'
8 | end
9 |
10 | def run_migrations
11 | if options[:migrate]
12 | run 'bundle exec rake db:migrate'
13 | else
14 | puts 'Skipping rake db:migrate, don\'t forget to run it!'
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/config/locales/pt-BR.yml:
--------------------------------------------------------------------------------
1 | ---
2 | pt-BR:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Relação
7 | other: Relaçãos
8 | spree/relation_type:
9 | one: Tipo de relação
10 | other: Tipos de relação
11 | spree:
12 | new_relation_type: Novo tipo de relacionamento
13 | related_product_discount: Desconto de produto relacionado
14 | applies_to: Aplicado à
15 | related_products: Produtos relacionados
16 | add_related_product: Adicionar produto relacionado
17 | name_or_sku_short: Nome ou SKU
18 | no_relation_types: Você precisa configurar os tipos de relacionamento antes de utilizar esta funcionalidade.
19 |
--------------------------------------------------------------------------------
/config/locales/de.yml:
--------------------------------------------------------------------------------
1 | ---
2 | de:
3 | activerecord:
4 | models:
5 | spree/relation:
6 | one: Verwandte
7 | other: Verhältnis
8 | spree/relation_type:
9 | one: Verwandschaftstyp
10 | other: Verwandschaftstypen
11 | spree:
12 | new_relation_type: Neuer Verwandschaftstyp
13 | related_product_discount: Skonto für verwandte Produkte
14 | applies_to: Anwenden auf
15 | related_products: Verwandte Produkte
16 | add_related_product: Verwandtes Produkt hinzufügen
17 | name_or_sku_short: Name oder Artikelnummer
18 | no_relation_types: "Sie müssen Verwandschaftstypen konfigurieren, bevor Sie diese Funktion nutzen können."
19 |
--------------------------------------------------------------------------------
/app/views/spree/admin/relation_types/new.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :page_title do %>
2 | <%= Spree.t(:new_relation_type) %>
3 | <% end %>
4 |
5 | <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @relation_type } %>
6 |
7 | <% content_for :page_actions do %>
8 | <%= button_link_to Spree.t(:back_to_resource_list, resource: Spree::RelationType.model_name.human), spree.admin_relation_types_path, class: 'btn-primary', icon: 'arrow-left' %>
9 | <% end %>
10 |
11 | <%= form_for :relation_type, url: collection_url do |f| %>
12 |
13 | <%= render 'form', f: f %>
14 | <%= render 'spree/admin/shared/new_resource_links' %>
15 |
16 | <% end %>
17 |
--------------------------------------------------------------------------------
/app/views/spree/admin/relation_types/edit.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :page_title do %>
2 | <%= Spree.t(:editing_resource, resource: Spree::RelationType.model_name.human) %>
3 | <% end %>
4 |
5 | <%= render partial: 'spree/admin/shared/error_messages', locals: { target: @relation_type } %>
6 |
7 | <% content_for :page_actions do %>
8 | <%= button_link_to Spree.t(:back_to_resource_list, resource: Spree::RelationType.model_name.human), spree.admin_relation_types_path, class: 'btn-primary', icon: 'arrow-left' %>
9 | <% end %>
10 |
11 | <%= form_for [:admin, @relation_type] do |f| %>
12 |
13 | <%= render 'form', f: f %>
14 | <%= render 'spree/admin/shared/edit_resource_links' %>
15 |
16 | <% end %>
17 |
--------------------------------------------------------------------------------
/Guardfile:
--------------------------------------------------------------------------------
1 | guard 'rspec', cmd: 'bundle exec rspec' do
2 | watch('spec/spec_helper.rb') { 'spec' }
3 | watch('config/routes.rb') { 'spec/controllers' }
4 | watch(%r{^spec/(.+)_spec\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
5 | watch(%r{^app/(.+)_decorator\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
6 | watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
7 | watch(%r{^app/(.*)(\.erb)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
8 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
9 | watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb" }
10 | end
11 |
--------------------------------------------------------------------------------
/spec/models/spree/relation_type_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::RelationType, type: :model do
2 | context 'relations' do
3 | it { is_expected.to have_many(:relations).dependent(:destroy) }
4 | end
5 |
6 | context 'validation' do
7 | it { is_expected.to validate_presence_of(:name) }
8 | it { is_expected.to validate_presence_of(:applies_to) }
9 | it { is_expected.to validate_uniqueness_of(:name).case_insensitive }
10 |
11 | it 'does not create duplicate names' do
12 | create(:relation_type, name: 'Gears')
13 | expect {
14 | create(:relation_type, name: 'gears')
15 | }.to raise_error(
16 | ActiveRecord::RecordInvalid, 'Validation failed: Name has already been taken'
17 | )
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/helpers/spree/related_products_helper.rb:
--------------------------------------------------------------------------------
1 | module Spree
2 | module RelatedProductsHelper
3 | def product_relations_by_type(relation_type)
4 | return [] if product_relation_types.none? || !@product.respond_to?(:relations)
5 |
6 | current_store.products.
7 | available.not_discontinued.
8 | joins(:reverse_relations).
9 | where(spree_relations: { relation_type: relation_type, relatable_id: @product.id, relatable_type: 'Spree::Product' }).
10 | includes(
11 | :tax_category,
12 | master: [
13 | :prices,
14 | { images: { attachment_attachment: :blob } },
15 | ]
16 | ).
17 | distinct(false).reorder('spree_relations.position').
18 | limit(Spree::Config[:products_per_page])
19 | end
20 | end
21 |
22 | # TODO: move all gem-related code from spree and spree_frontend gems
23 | # to this gem, and then do this in the proper way.
24 | ProductsHelper.prepend RelatedProductsHelper
25 | end
--------------------------------------------------------------------------------
/app/views/spree/admin/relation_types/_form.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= f.label :name, Spree.t(:name) %> *
5 | <%= f.text_field :name, class: 'form-control' %>
6 | <%= error_message_on :relation_type, :name %>
7 |
8 |
9 | <%= f.label :applies_to, Spree.t(:applies_to) %> *
10 | <%= f.text_field :applies_to, value: f.object.nil? ? 'Spree::Product' : f.object.applies_to, class: 'form-control' %>
11 | <%= error_message_on :relation_type, :applies_to %>
12 |
13 |
14 | <%= f.label :description, Spree.t(:description) %>
15 | <%= f.text_area :description, rows: 4, class: 'form-control' %>
16 | <%= error_message_on :relation_type, :description %>
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/app/assets/javascripts/spree/frontend/views/spree/product/related.js:
--------------------------------------------------------------------------------
1 | //= require spree/frontend/viewport
2 |
3 | Spree.fetchRelatedProducts = function (id, htmlContainer) {
4 | return $.ajax({
5 | url: Spree.routes.product_related(id)
6 | }).done(function (data) {
7 | htmlContainer.replaceWith(data)
8 | htmlContainer.find('.carousel').carouselBootstrap4()
9 | })
10 | }
11 |
12 | document.addEventListener('turbo:load', function () {
13 | var productDetailsPage = $('body#product-details')
14 |
15 | if (productDetailsPage.length) {
16 | var productId = $('div[data-related-products]').attr('data-related-products-id')
17 | var relatedProductsEnabled = $('div[data-related-products]').attr('data-related-products-enabled')
18 | var relatedProductsFetched = false
19 | var relatedProductsContainer = $('#related-products')
20 |
21 | if (!relatedProductsFetched && relatedProductsContainer.length && relatedProductsEnabled && relatedProductsEnabled === 'true' && productId !== '') {
22 | $(window).on('resize scroll', function () {
23 | if (!relatedProductsFetched && relatedProductsContainer.isInViewport()) {
24 | Spree.fetchRelatedProducts(productId, relatedProductsContainer)
25 | relatedProductsFetched = true
26 | }
27 | })
28 | }
29 | }
30 | })
31 |
--------------------------------------------------------------------------------
/spree_related_products.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path('../lib/', __FILE__)
2 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3 |
4 | require 'spree_related_products/version'
5 |
6 | Gem::Specification.new do |s|
7 | s.platform = Gem::Platform::RUBY
8 | s.name = 'spree_related_products'
9 | s.version = SpreeRelatedProducts.version
10 | s.summary = 'Allows multiple types of relationships between products to be defined'
11 | s.description = s.summary
12 | s.required_ruby_version = '>= 2.2.7'
13 |
14 | s.author = 'Brian Quinn'
15 | s.email = 'brian@railsdog.com'
16 | s.homepage = 'https://github.com/spree-contrib/spree_related_products'
17 | s.license = 'BSD-3'
18 |
19 | s.files = `git ls-files`.split("\n")
20 | s.test_files = `git ls-files -- spec/*`.split("\n")
21 | s.require_path = 'lib'
22 | s.requirements << 'none'
23 |
24 | spree_version = ">= #{s.version}"
25 | s.add_runtime_dependency 'spree_core', spree_version
26 | s.add_runtime_dependency 'spree_backend', spree_version
27 | s.add_runtime_dependency 'spree_api_v1', '>= 4.5.0'
28 | s.add_runtime_dependency 'spree_extension'
29 | s.add_runtime_dependency 'deface', '~> 1.0'
30 |
31 | s.add_development_dependency 'spree_dev_tools'
32 | s.add_development_dependency 'shoulda-matchers'
33 | end
34 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | os: linux
2 | dist: bionic
3 |
4 | addons:
5 | apt:
6 | sources:
7 | - google-chrome
8 | packages:
9 | - google-chrome-stable
10 |
11 | services:
12 | - mysql
13 | - postgresql
14 |
15 | language: ruby
16 |
17 | rvm:
18 | - 2.7
19 | - 3.0
20 |
21 | env:
22 | - DB=mysql
23 | - DB=postgres
24 |
25 | gemfile:
26 | - gemfiles/spree_3_7.gemfile
27 | - gemfiles/spree_4_0.gemfile
28 | - gemfiles/spree_4_1.gemfile
29 | - gemfiles/spree_4_2.gemfile
30 | - gemfiles/spree_master.gemfile
31 |
32 | jobs:
33 | exclude:
34 | - rvm: 3.0
35 | gemfile: gemfiles/spree_3_7.gemfile
36 | - rvm: 3.0
37 | gemfile: gemfiles/spree_4_0.gemfile
38 | - rvm: 3.0
39 | gemfile: gemfiles/spree_4_1.gemfile
40 | allow_failures:
41 | - gemfile: gemfiles/spree_master.gemfile
42 |
43 | before_install:
44 | - mysql -u root -e "GRANT ALL ON *.* TO 'travis'@'%';"
45 |
46 | before_script:
47 | - CHROME_MAIN_VERSION=`google-chrome-stable --version | sed -E 's/(^Google Chrome |\.[0-9]+ )//g'`
48 | - CHROMEDRIVER_VERSION=`curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_MAIN_VERSION"`
49 | - curl "https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" -O
50 | - unzip chromedriver_linux64.zip -d ~/bin
51 | - nvm install 14
52 |
53 | script:
54 | - bundle exec rake test_app
55 | - bundle exec rake spec
56 |
--------------------------------------------------------------------------------
/lib/spree_related_products/engine.rb:
--------------------------------------------------------------------------------
1 | require 'spree_api_v1'
2 |
3 | module SpreeRelatedProducts
4 | class Engine < Rails::Engine
5 | require 'spree/core'
6 | isolate_namespace Spree
7 | engine_name 'spree_related_products'
8 |
9 | config.autoload_paths += %W(#{config.root}/lib)
10 |
11 | # Promotion rules need to be evaluated on after initialize otherwise
12 | # Spree.user_class would be nil and users might experience errors related
13 | # to malformed model associations (Spree.user_class is only defined on
14 | # the app initializer)
15 | config.after_initialize do
16 | config.spree.calculators.promotion_actions_create_adjustments << Spree::Calculator::RelatedProductDiscount
17 | end
18 |
19 | initializer "let the main autoloader ignore this engine's overrides" do
20 | overrides = root.join("app/overrides")
21 | Rails.autoloaders.main.ignore(overrides)
22 | end
23 |
24 | class << self
25 | def activate
26 | cache_klasses = %W(#{config.root}/app/**/*_decorator*.rb)
27 | Dir.glob(cache_klasses) do |klass|
28 | Rails.configuration.cache_classes ? require(klass) : load(klass)
29 | end
30 |
31 | Dir.glob(File.join(File.dirname(__FILE__), "../../app/overrides/*.rb")) do |c|
32 | load(c)
33 | end
34 | end
35 | end
36 |
37 | config.to_prepare(&method(:activate).to_proc)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2021 Brian Quinn and contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 | * Neither the name Spree nor the names of its contributors may be used to
13 | endorse or promote products derived from this software without specific
14 | prior written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 |
--------------------------------------------------------------------------------
/app/models/spree/calculator/related_product_discount.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_dependency 'spree/calculator'
3 |
4 | module Spree
5 | class Calculator::RelatedProductDiscount < Calculator
6 | def self.description
7 | Spree.t(:related_product_discount)
8 | end
9 |
10 | def compute(object)
11 | if object.is_a?(Array)
12 | return if object.empty?
13 | order = object.first.order
14 | else
15 | order = object
16 | end
17 |
18 | total = 0
19 | if eligible?(order)
20 | total = order.line_items.inject(0) do |sum, line_item|
21 | relations = Spree::Relation.where(*discount_query(line_item))
22 | discount_applies_to = relations.map {|rel| rel.related_to.master.product }
23 | order.line_items.each do |li|
24 | next unless discount_applies_to.include? li.variant.product
25 | discount = relations.detect { |rel| rel.related_to.master.product == li.variant.product }.discount_amount
26 | sum += if li.quantity < line_item.quantity
27 | (discount * li.quantity)
28 | else
29 | (discount * line_item.quantity)
30 | end
31 | end
32 |
33 | sum
34 | end
35 | end
36 |
37 | total
38 | end
39 |
40 | def eligible?(order)
41 | order.line_items.any? do |line_item|
42 | Spree::Relation.exists?(discount_query(line_item))
43 | end
44 | end
45 |
46 | def discount_query(line_item)
47 | [
48 | 'discount_amount <> 0.0 AND relatable_type = ? AND relatable_id = ?',
49 | 'Spree::Product',
50 | line_item.variant.product.id
51 | ]
52 | end
53 | end
54 | end
--------------------------------------------------------------------------------
/app/views/spree/admin/relation_types/index.html.erb:
--------------------------------------------------------------------------------
1 | <% content_for :page_title do %>
2 | <%= Spree::RelationType.model_name.human(count: 2.1) %>
3 | <% end %>
4 |
5 | <% content_for :page_actions do %>
6 | <%= button_link_to Spree.t(:new_relation_type), new_object_url, class: 'btn-primary', icon: 'add', id: 'admin_new_relation_type' %>
7 | <% end %>
8 |
9 | <% if @relation_types.any? %>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | <%= Spree.t(:name) %>
20 | <%= Spree.t(:applies_to) %>
21 | <%= Spree.t(:description) %>
22 |
23 |
24 |
25 |
26 | <% @relation_types.each do |relation_type| %>
27 |
28 | <%= relation_type.name %>
29 | <%= relation_type.applies_to %>
30 | <%= relation_type.description %>
31 |
32 | <%= link_to_edit relation_type, no_text: true %>
33 | <%= link_to_delete relation_type, no_text: true %>
34 |
35 |
36 | <% end %>
37 |
38 |
39 | <% else %>
40 |
41 | <%= Spree.t(:no_resource_found, resource: Spree::RelationType.model_name.human(count: 2.1)) %>,
42 | <%= link_to Spree.t(:add_one), spree.new_admin_relation_type_path %>!
43 |
44 | <% end %>
45 |
--------------------------------------------------------------------------------
/app/views/spree/admin/products/_related_products_table.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | <%= Spree.t(:name) %>
13 | <%= Spree.t(:type) %>
14 | <%= Spree.t(:discount_amount) %>
15 |
16 |
17 |
18 |
19 | <% product.relations.each do |relation| %>
20 |
21 |
22 | <% if Spree.version.to_d >= 4.2 && defined?(svg_icon) %>
23 | <%= svg_icon name: "sort.svg", width: '18', height: '18' %>
24 | <% else %>
25 |
26 | <% end %>
27 |
28 | <% if defined? Spree::Frontend %>
29 | <%= link_to relation.related_to.name, relation.related_to %>
30 | <% else %>
31 | <%= link_to relation.related_to.name, admin_product_path(relation.related_to) %>
32 | <% end %>
33 | <%= relation.relation_type.name %>
34 |
35 | <%= form_for relation, url: admin_product_relation_path(relation.relatable, relation) do |f| %>
36 |
37 | <%= f.text_field :discount_amount, class: 'form-control text-center my-1 w-50' %>
38 |
39 | <%= f.button Spree.t(:update), type: 'submit', class: 'btn btn-primary m-1' %>
40 |
41 |
42 | <% end %>
43 |
44 |
45 | <%= link_to_delete relation, url: admin_product_relation_url(relation.relatable, relation), no_text: true %>
46 |
47 |
48 | <% end %>
49 |
50 |
51 |
--------------------------------------------------------------------------------
/app/controllers/spree/api/relations_controller.rb:
--------------------------------------------------------------------------------
1 | module Spree
2 | module Api
3 | class RelationsController < Spree::Api::V1::BaseController
4 | include ActionController::MimeResponds
5 |
6 | before_action :load_data, only: [:create, :destroy]
7 | before_action :find_relation, only: [:update, :destroy]
8 |
9 | def create
10 | authorize! :create, Relation
11 | @relation = @product.relations.new(relation_params)
12 | @relation.relatable = @product
13 | @relation.related_to = Spree::Variant.find(relation_params[:related_to_id]).product
14 | if @relation.save
15 | respond_with(@relation, status: 201, default_template: :show)
16 | else
17 | invalid_resource!(@relation)
18 | end
19 | end
20 |
21 | def update
22 | authorize! :update, Relation
23 | if @relation.update(relation_params)
24 | respond_with(@relation, status: 200, default_template: :show)
25 | else
26 | invalid_resource!(@relation)
27 | end
28 | end
29 |
30 | def update_positions
31 | authorize! :update, Relation
32 | params[:positions].each do |id, index|
33 | model_class.where(id: id).update_all(position: index)
34 | end
35 |
36 | respond_to do |format|
37 | format.json { head :ok }
38 | format.js { render plain: 'Ok' }
39 | end
40 | end
41 |
42 | def destroy
43 | authorize! :destroy, Relation
44 | @relation.destroy
45 | respond_with(@relation, status: 204)
46 | end
47 |
48 | private
49 |
50 | def relation_params
51 | params.require(:relation).permit(*permitted_attributes)
52 | end
53 |
54 | def permitted_attributes
55 | [
56 | :related_to,
57 | :relation_type,
58 | :relatable,
59 | :related_to_id,
60 | :discount_amount,
61 | :relation_type_id,
62 | :related_to_type,
63 | :position
64 | ]
65 | end
66 |
67 | def load_data
68 | @product = Spree::Product.friendly.find(params[:product_id])
69 | end
70 |
71 | def find_relation
72 | @relation = Relation.find(params[:id])
73 | end
74 |
75 | def model_class
76 | Spree::Relation
77 | end
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/app/controllers/spree/admin/relations_controller.rb:
--------------------------------------------------------------------------------
1 | module Spree
2 | module Admin
3 | class RelationsController < BaseController
4 | before_action :load_data, only: [:create, :destroy]
5 |
6 | respond_to :js, :html
7 |
8 | def create
9 | @relation = Relation.new(relation_params)
10 | @relation.relatable = @product
11 | @relation.related_to = Spree::Variant.find(relation_params[:related_to_id]).product
12 | @relation.save
13 |
14 | respond_to do |format|
15 | format.js { render :create, layout: false }
16 | end
17 | end
18 |
19 | def update
20 | @relation = Relation.find(params[:id])
21 | @relation.update_attribute :discount_amount, relation_params[:discount_amount] || 0
22 |
23 | redirect_to(related_admin_product_url(@relation.relatable))
24 | end
25 |
26 | def update_positions
27 | params[:positions].each do |id, index|
28 | model_class.where(id: id).update_all(position: index)
29 | end
30 |
31 | respond_to do |format|
32 | format.js { render plain: 'Ok' }
33 | end
34 | end
35 |
36 | def destroy
37 | @relation = Relation.find(params[:id])
38 | @relation.destroy
39 |
40 | if @relation.destroy
41 | flash[:success] = flash_message_for(@relation, :successfully_removed)
42 |
43 | respond_with(@relation) do |format|
44 | format.html { redirect_to location_after_destroy }
45 | format.js { render_js_for_destroy }
46 | end
47 | else
48 | respond_with(@relation) do |format|
49 | format.html { redirect_to location_after_destroy }
50 | end
51 | end
52 | end
53 |
54 | private
55 |
56 | def relation_params
57 | params.require(:relation).permit(*permitted_attributes)
58 | end
59 |
60 | def permitted_attributes
61 | [
62 | :related_to,
63 | :relation_type,
64 | :relatable,
65 | :related_to_id,
66 | :discount_amount,
67 | :relation_type_id,
68 | :related_to_type,
69 | :position
70 | ]
71 | end
72 |
73 | def load_data
74 | @product = Spree::Product.friendly.find(params[:product_id])
75 | end
76 |
77 | def model_class
78 | Spree::Relation
79 | end
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/spec/features/spree/admin/product_relation_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.feature 'Admin Product Relation', :js do
2 | stub_authorization!
3 |
4 | given!(:product) { create(:product) }
5 | given!(:other) { create(:product) }
6 |
7 | given!(:relation_type) { create(:relation_type, name: 'Gears') }
8 |
9 | background do
10 | visit spree.edit_admin_product_path(product)
11 | click_link 'Related Products'
12 | end
13 |
14 | scenario 'create relation' do
15 | expect(page).to have_text 'Add Related Product'
16 | expect(page).to have_text product.name
17 |
18 | within('#add-line-item') do
19 | if Spree.version.to_f >= 4.1
20 | select2 other.name, from: 'Name or SKU', search: true, match: :first
21 | select2 relation_type.name, from: 'Type', search: true, match: :first
22 | else
23 | select2_search other.name, from: 'Name or SKU'
24 | select2_search relation_type.name, from: 'Type'
25 | end
26 | fill_in 'add_discount', with: '0.8'
27 | click_link 'Add'
28 | end
29 |
30 | wait_for_ajax
31 |
32 | within_row(1) do
33 | expect(page).to have_field('relation_discount_amount', with: '0.8')
34 | expect(column_text(2)).to eq other.name
35 | expect(column_text(3)).to eq relation_type.name
36 | end
37 | end
38 |
39 | context 'with relations' do
40 | given!(:relation) do
41 | create(:relation, relatable: product, related_to: other, relation_type: relation_type, discount_amount: 0.5)
42 | end
43 |
44 | background do
45 | visit spree.edit_admin_product_path(product)
46 | click_link 'Related Products'
47 | end
48 |
49 | scenario 'ensure content exist' do
50 | expect(page).to have_text 'Add Related Product'
51 | expect(page).to have_text product.name
52 | expect(page).to have_text other.name
53 |
54 | within_row(1) do
55 | expect(page).to have_field('relation_discount_amount', with: '0.5')
56 | expect(column_text(2)).to eq other.name
57 | expect(column_text(3)).to eq relation_type.name
58 | end
59 | end
60 |
61 | scenario 'update discount' do
62 | within_row(1) do
63 | fill_in 'relation_discount_amount', with: '0.9'
64 | click_on 'Update'
65 | end
66 | wait_for_ajax
67 | within_row(1) do
68 | expect(page).to have_field('relation_discount_amount', with: '0.9')
69 | end
70 | end
71 |
72 | context 'delete' do
73 | scenario 'can remove records' do
74 | within_row(1) do
75 | expect(column_text(2)).to eq other.name
76 | click_icon :delete
77 | end
78 | page.driver.browser.switch_to.alert.accept unless Capybara.javascript_driver == :poltergeist
79 | wait_for_ajax
80 | expect(page).to have_text 'successfully removed!'
81 | expect(page).not_to have_text other.name
82 | end
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/spec/features/spree/admin/relation_types_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.feature 'Admin Relation Types', :js do
2 | stub_authorization!
3 |
4 | background do
5 | visit spree.admin_relation_types_path
6 | end
7 |
8 | scenario 'when no relation types exists' do
9 | expect(page).to have_text 'No Relation Types found, Add One!'
10 | end
11 |
12 | context 'create' do
13 | scenario 'can create a new relation type' do
14 | click_link 'New Relation Type'
15 | expect(current_path).to eq spree.new_admin_relation_type_path
16 |
17 | fill_in 'Name', with: 'Gears'
18 | fill_in 'Applies To', with: 'Spree:Products'
19 |
20 | click_button 'Create'
21 |
22 | expect(page).to have_text 'successfully created!'
23 | expect(current_path).to eq spree.admin_relation_types_path
24 | end
25 |
26 | scenario 'shows validation errors with blank :name' do
27 | click_link 'New Relation Type'
28 | expect(current_path).to eq spree.new_admin_relation_type_path
29 |
30 | fill_in 'Name', with: ''
31 | click_button 'Create'
32 |
33 | expect(page).to have_text 'Name can\'t be blank'
34 | end
35 |
36 | scenario 'shows validation errors with blank :applies_to' do
37 | click_link 'New Relation Type'
38 | expect(current_path).to eq spree.new_admin_relation_type_path
39 |
40 | fill_in 'Name', with: 'Gears'
41 | fill_in 'Applies To', with: ''
42 | click_button 'Create'
43 |
44 | expect(page).to have_text 'Applies to can\'t be blank'
45 | end
46 | end
47 |
48 | context 'with records' do
49 | background do
50 | %w(Gears Equipments).each do |name|
51 | create(:relation_type, name: name)
52 | end
53 | visit spree.admin_relation_types_path
54 | end
55 |
56 | context 'show' do
57 | scenario 'displays existing relation types' do
58 | within_row(1) do
59 | expect(column_text(1)).to eq 'Gears'
60 | expect(column_text(2)).to eq 'Spree::Product'
61 | expect(column_text(3)).to eq ''
62 | end
63 | end
64 | end
65 |
66 | context 'edit' do
67 | background do
68 | within_row(1) { click_icon :edit }
69 | expect(current_path).to eq spree.edit_admin_relation_type_path(1)
70 | end
71 |
72 | scenario 'can update an existing relation type' do
73 | fill_in 'Name', with: 'Gadgets'
74 | click_button 'Update'
75 | expect(page).to have_text 'successfully updated!'
76 | expect(page).to have_text 'Gadgets'
77 | end
78 |
79 | scenario 'shows validation errors with blank :name' do
80 | fill_in 'Name', with: ''
81 | click_button 'Update'
82 | expect(page).to have_text 'Name can\'t be blank'
83 | end
84 | end
85 |
86 | context 'delete' do
87 | scenario 'can remove records' do
88 | within_row(1) do
89 | expect(column_text(1)).to eq 'Gears'
90 | click_icon :delete
91 | end
92 | page.driver.browser.switch_to.alert.accept unless Capybara.javascript_driver == :poltergeist
93 | expect(page).to have_text 'successfully removed!'
94 | end
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/spec/controllers/spree/api/relations_controller_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Api::RelationsController, type: :controller do
2 | stub_authorization!
3 | render_views
4 |
5 | let(:user) { create(:user) }
6 | let!(:product) { create(:product) }
7 | let!(:other1) { create(:product) }
8 |
9 | let!(:relation_type) { create(:relation_type) }
10 | let!(:relation) { create(:relation, relatable: product, related_to: other1, relation_type: relation_type, position: 0) }
11 |
12 | before do
13 | user.generate_spree_api_key!
14 | allow(controller).to receive(:spree_current_user).and_return(user)
15 | end
16 |
17 | context 'model_class' do
18 | it 'responds to model_class as Spree::Relation' do
19 | expect(controller.send(:model_class)).to eq Spree::Relation
20 | end
21 | end
22 |
23 | describe 'with JSON' do
24 | let(:valid_params) do
25 | {
26 | format: :json,
27 | product_id: product.id,
28 | relation: {
29 | related_to_id: other1.id,
30 | relation_type_id: relation_type.id
31 | },
32 | token: user.spree_api_key
33 | }
34 | end
35 |
36 | context '#create' do
37 | it 'creates the relation' do
38 | expect {
39 | post :create, params: valid_params
40 | }.to change(Spree::Relation, :count).by(1)
41 | end
42 |
43 | it 'responds 422 error with invalid params' do
44 | params = {
45 | format: :json,
46 | product_id: product.id,
47 | token: user.spree_api_key
48 | }
49 |
50 | post :create, params: params
51 | expect(response.status).to eq(422)
52 | end
53 | end
54 |
55 | context '#update' do
56 | it 'succesfully updates the relation ' do
57 | params = { format: :json, product_id: product.id, id: relation.id, relation: { discount_amount: 2.0 }, token: user.spree_api_key }
58 | expect {
59 | put :update, params: params
60 | }.to change { relation.reload.discount_amount.to_s }.from('0.0').to('2.0')
61 | end
62 | end
63 |
64 | context '#destroy with' do
65 | it 'records successfully' do
66 | params = {
67 | id: relation.id,
68 | product_id: product.id,
69 | format: :json,
70 | token: user.spree_api_key
71 | }
72 |
73 | expect {
74 | delete :destroy, params: params
75 | }.to change(Spree::Relation, :count).by(-1)
76 | end
77 | end
78 |
79 | context '#update_positions' do
80 | it 'returns the correct position of the related products' do
81 | other2 = create(:product)
82 | relation2 = create(:relation, relatable: product, related_to: other2, relation_type: relation_type, position: 1)
83 | params = {
84 | product_id: product.id,
85 | id: relation.id,
86 | positions: {
87 | relation.id => '1',
88 | relation2.id => '0'
89 | },
90 | format: :json,
91 | token: user.spree_api_key
92 | }
93 |
94 | expect {
95 | post :update_positions, params: params
96 | relation.reload
97 | }.to change(relation, :position).from(0).to(1)
98 | end
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Spree Related Products is an open source project and we encourage contributions. Please see the [contributors guidelines](http://spreecommerce.com/documentation/contributing_to_spree.html) for more information before contributing.
4 |
5 | In the spirit of [free software][1], **everyone** is encouraged to help improve this project.
6 |
7 | Here are some ways *you* can contribute:
8 |
9 | * by using prerelease versions
10 | * by reporting [bugs][2]
11 | * by suggesting new features
12 | * by writing [translations][3]
13 | * by writing or editing documentation
14 | * by writing specifications
15 | * by writing code (*no patch is too small*: fix typos, add comments, clean up inconsistent whitespace)
16 | * by refactoring code
17 | * by resolving [issues][2]
18 | * by reviewing patches
19 |
20 | ---
21 |
22 | ## Filing an issue
23 |
24 | When filing an issue on this extension, please first do these things:
25 |
26 | * Verify you can reproduce this issue in a brand new application.
27 | * Run through the steps to reproduce the issue again.
28 |
29 | In the issue itself please provide:
30 |
31 | * A comprehensive list of steps to reproduce the issue.
32 | * What you're *expecting* to happen compared with what's *actually* happening.
33 | * The version of Spree *and* the version of Rails.
34 | * A list of all extensions.
35 | * Any relevant stack traces ("Full trace" preferred)
36 | * Your `Gemfile`
37 |
38 | In 99% of cases, this information is enough to determine the cause and solution to the problem that is being described.
39 |
40 | ---
41 |
42 | ## Pull requests
43 |
44 | We gladly accept pull requests to fix bugs and, in some circumstances, add new features to this extension.
45 |
46 | Here's a quick guide:
47 |
48 | 1. Fork the repo.
49 |
50 | 2. Run the tests. We only take pull requests with passing tests, and it's great to know that you have a clean slate.
51 |
52 | 3. 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!
53 |
54 | 4. Push to your fork and submit a pull request. If the changes will apply cleanly to the latest stable branches and master branch, you will only need to submit one pull request.
55 |
56 | At this point you're waiting on us. We may suggest some changes or improvements or alternatives.
57 |
58 | Some things that will increase the chance that your pull request is accepted, taken straight from the Ruby on Rails guide:
59 |
60 | * Use Rails idioms and helpers.
61 | * Include tests that fail without your code, and pass with it.
62 | * Update the documentation, the surrounding one, examples elsewhere, guides, whatever is affected by your contribution.
63 |
64 | ---
65 |
66 | ## TL;DR
67 |
68 | * Fork the repo
69 | * Clone your repo
70 | * Run `bundle install`
71 | * Run `bundle exec rake test_app` to create the test application in `spec/dummy`
72 | * Make your changes
73 | * Ensure specs pass by running `bundle exec rspec spec`
74 | * Ensure all syntax ok by running `rubocop .`
75 | * Submit your pull request
76 |
77 | And in case we didn't emphasize it enough: **we love tests!**
78 |
79 | [1]: http://www.fsf.org/licensing/essays/free-sw.html
80 | [2]: https://github.com/spree-contrib/spree_related_products/issues
81 | [3]: https://github.com/spree-contrib/spree_related_products/tree/master/config/locales
82 |
--------------------------------------------------------------------------------
/spec/controllers/spree/admin/relations_controller_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Admin::RelationsController, type: :controller do
2 | stub_authorization!
3 |
4 | let(:user) { create(:user) }
5 | let!(:product) { create(:product) }
6 | let!(:other1) { create(:product) }
7 |
8 | let!(:relation_type) { create(:relation_type) }
9 | let!(:relation) { create(:relation, relatable: product, related_to: other1, relation_type: relation_type, position: 0) }
10 |
11 | before do
12 | user.generate_spree_api_key!
13 | allow(controller).to receive(:spree_current_user).and_return(user)
14 | end
15 |
16 | context '.model_class' do
17 | it 'responds to model_class as Spree::Relation' do
18 | expect(controller.send(:model_class)).to eq Spree::Relation
19 | end
20 | end
21 |
22 | describe 'with JS' do
23 | let(:valid_params) do
24 | {
25 | format: :js,
26 | product_id: product.id,
27 | relation: {
28 | related_to_id: other1.id,
29 | relation_type_id: relation_type.id
30 | },
31 | token: user.spree_api_key
32 | }
33 | end
34 |
35 | context '#create' do
36 | it 'is not routable' do
37 | post :create, params: valid_params
38 | expect(response.status).to be(200)
39 | end
40 |
41 | it 'returns success with valid params' do
42 | expect {
43 | post :create, params: valid_params
44 | }.to change(Spree::Relation, :count).by(1)
45 | end
46 |
47 | it 'raises error with invalid params' do
48 | expect {
49 | post :create, format: :js
50 | }.to raise_error(
51 | ActionController::UrlGenerationError, 'No route matches {:action=>"create", :controller=>"spree/admin/relations", :format=>:js}'
52 | )
53 | end
54 | end
55 |
56 | context '#update' do
57 | it 'redirects to product/related url' do
58 | params = {
59 | product_id: product.id,
60 | id: relation.id,
61 | relation: { discount_amount: 2.0 }
62 | }
63 |
64 | put :update, params: params
65 | expect(response).to redirect_to(spree.admin_product_path(relation.relatable) + '/related')
66 | end
67 | end
68 |
69 | context '#destroy' do
70 | it 'records successfully' do
71 | params = {
72 | id: relation.id,
73 | product_id: relation.relatable_id,
74 | format: :js
75 | }
76 |
77 | expect {
78 | delete :destroy, params: params
79 | }.to change(Spree::Relation, :count).by(-1)
80 | end
81 | end
82 |
83 | context '#update_positions' do
84 | it 'returns the correct position of the related products' do
85 | other2 = create(:product)
86 | relation2 = create(:relation, relatable: product, related_to: other2, relation_type: relation_type, position: 1)
87 | params = {
88 | product_id: product.id,
89 | id: relation.id,
90 | positions: {
91 | relation.id => '1',
92 | relation2.id => '0'
93 | },
94 | format: :js
95 | }
96 |
97 | expect {
98 | post :update_positions, params: params
99 | relation.reload
100 | }.to change(relation, :position).from(0).to(1)
101 | end
102 | end
103 | end
104 | end
105 |
--------------------------------------------------------------------------------
/app/views/spree/admin/products/related.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'spree/admin/shared/product_tabs', locals: { current: 'Related Products' } %>
2 | <%= render partial: 'spree/admin/variants/autocomplete', formats: :js %>
3 |
4 | <%= csrf_meta_tag %>
5 |
6 | <% if frontend_available? %>
7 | <%= content_for :page_actions do %>
8 | <%=
9 | button_link_to(
10 | Spree.t(:preview_product),
11 | spree.product_url(@product),
12 | class: 'btn-outline-secondary', icon: 'eye-open', id: 'admin_preview_product', target: :blank
13 | )
14 | %>
15 | <% end %>
16 | <% end %>
17 |
18 | <% if @relation_types.empty? %>
19 |
20 | <%= Spree.t(:no_relation_types) %>
21 |
22 | <% else %>
23 |
24 |
25 | <%= Spree.t(:add_related_product) %>
26 |
55 |
56 |
57 |
58 |
59 | <%= render 'related_products_table', product: @product %>
60 |
61 |
62 | <%= content_for :head do %>
63 |
87 | <% end %>
88 | <% end %>
89 |
--------------------------------------------------------------------------------
/spec/models/spree/calculator/related_product_discount_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Calculator::RelatedProductDiscount, type: :model do
2 | subject { described_class.new }
3 |
4 | context '.description' do
5 | it 'outputs relation product discount' do
6 | expect(subject.description).to eq Spree.t(:related_product_discount)
7 | end
8 | end
9 |
10 | describe '.compute(object)' do
11 | it 'returns nil with empty Array' do
12 | expect(subject.compute([])).to be_nil
13 | end
14 |
15 | it 'returns 0 unless order is eligible' do
16 | empty_order = double('Spree::Order')
17 | allow(empty_order).to receive(:line_items).and_return([])
18 | expect(subject.compute(empty_order)).to be_zero
19 | end
20 |
21 | context 'with order with single item' do
22 | before do
23 | @order = double('Spree::Order')
24 | product = build(:product)
25 | variant = double('Spree::Variant', product: product)
26 | price = double('Spree::Price', variant: variant, amount: 5.00)
27 | line_item = double('Spree::LineItem', variant: variant, order: @order, quantity: 1, price: 4.99)
28 |
29 | allow(variant).to receive(:default_price).and_return(price)
30 | allow(@order).to receive(:line_items).and_return([line_item])
31 |
32 | related_product = create(:product)
33 | relation_type = create(:relation_type)
34 |
35 | create(:relation, relatable: product, related_to: related_product, relation_type: relation_type, discount_amount: 1.0)
36 | end
37 |
38 | it 'returns total count of Array' do
39 | objects = Array.new { @order }
40 | expect(subject.compute(objects)).to be_nil
41 | end
42 |
43 | it 'returns total count' do
44 | expect(subject.compute(@order)).to be_zero
45 | end
46 | end
47 |
48 | context 'with order with related items' do
49 | before do
50 | @order = double('Spree::Order')
51 | product = build_stubbed(:product)
52 | variant = double('Spree::Variant', product: product)
53 | price = double('Spree::Price', variant: variant, amount: 5.00)
54 | @line_item = double('Spree::LineItem', variant: variant, order: @order, quantity: 1, price: 4.99)
55 | @two_line_item = double('Spree::LineItem', variant: variant, order: @order, quantity: 2, price: 4.99)
56 |
57 | allow(variant).to receive(:default_price).and_return(price)
58 |
59 | related_product = create(:product)
60 | related_variant = double('Spree::Variant', product: related_product)
61 | related_price = double('Spree::Price', variant: related_variant, amount: 5.00)
62 | @related_line_item = double('Spree::LineItem', variant: related_variant, order: @order, quantity: 1, price: 4.99)
63 | @two_related_line_item = double('Spree::LineItem', variant: related_variant, order: @order, quantity: 2, price: 4.99)
64 |
65 | allow(related_variant).to receive(:default_price).and_return(related_price)
66 |
67 | related_product_2 = create(:product)
68 | relation_type = create(:relation_type)
69 |
70 | create(:relation, relatable: product, related_to: related_product, relation_type: relation_type, discount_amount: 2.35)
71 | create(:relation, relatable: product, related_to: related_product_2, relation_type: relation_type, discount_amount: 0.0)
72 | end
73 |
74 | it 'returns total discount for one related item' do
75 | allow(@order).to receive(:line_items).and_return([@line_item, @related_line_item])
76 | expect(subject.compute(@order)).to eq 2.35
77 | end
78 |
79 | it 'returns total discount for 2 related items' do
80 | allow(@order).to receive(:line_items).and_return([@two_line_item, @two_related_line_item])
81 | expect(subject.compute(@order)).to eq 2*2.35
82 | end
83 | end
84 |
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Related Products
2 |
3 | [](https://travis-ci.org/spree-contrib/spree_related_products)
4 | [](https://codeclimate.com/github/spree-contrib/spree_related_products)
5 |
6 | Related Products is a [Spree Commerce](https://github.com/spree/spree) extension that provides a generic way for you to define different types of relationships between your products, by defining a RelationType for each type of relationship you'd like to maintain.
7 |
8 | You can manage RelationTypes via the admin configuration menu, and you can maintain product relationships via __Related Products__ tab on the edit product UI.
9 |
10 | ### Possible uses
11 |
12 | * Accessories
13 | * Cross Sells
14 | * Up Sells
15 | * Compatible Products
16 | * Replacement Products
17 | * Warranty & Support Products
18 |
19 | ### Relation Types
20 |
21 | When you create a RelationType you can access that set of related products by referencing the relation_type name, see below for an example:
22 | ```ruby
23 | rt = Spree::RelationType.create(name: 'Accessories', applies_to: 'Spree::Product')
24 | => #
25 | product = Spree::Product.last
26 | => #
27 | product.accessories
28 | => []
29 | ```
30 |
31 | Since respond_to? will not work in this case, you can test whether a relation_type method exists with has_related_products?(method):
32 |
33 | ```ruby
34 | product.has_related_products?('accessories')
35 | # => true
36 |
37 | if product.has_related_products?('accessories')
38 | # Display an accessories box..
39 | end
40 | ```
41 |
42 | You can access all related products regardless of RelationType by:
43 | ```ruby
44 | product.relations
45 | => []
46 | ```
47 |
48 | **Discounts**
49 | You can optionally specify a discount amount to be applied if a customer purchases both products.
50 |
51 | Note: In order for the coupon to be automatically applied, you must create a promotion leaving the __code__ value empty, and adding an Action of type : __RelatedProductDiscount__ (blank codes are required for coupons to be automatically applied).
52 |
53 | ---
54 |
55 | ## Installation
56 |
57 | 1. Add this extension to your Gemfile with this line:
58 |
59 | #### Spree >= 3.1
60 |
61 | ```ruby
62 | gem 'spree_related_products', github: 'spree-contrib/spree_related_products'
63 | ```
64 |
65 | #### Spree 3.0 and Spree 2.x
66 |
67 | ```ruby
68 | gem 'spree_related_products', github: 'spree-contrib/spree_related_products', branch: 'X-X-stable'
69 | ```
70 |
71 | The `branch` option is important: it must match the version of Spree you're using.
72 | For example, use `3-0-stable` if you're using Spree `3-0-stable` or any `3.0.x` version.
73 |
74 | 2. Install the gem using Bundler:
75 | ```ruby
76 | bundle install
77 | ```
78 |
79 | 3. Copy & run migrations
80 | ```ruby
81 | bundle exec rails g spree_related_products:install
82 | ```
83 |
84 | 4. Add to your `vendor/assets/stylesheets/spree/frontend/all.css`
85 | ```
86 | *= require spree/related_products
87 | ```
88 |
89 | 4. Restart your server
90 |
91 | If your server was running, restart it so that it can find the assets properly.
92 |
93 |
94 | ---
95 |
96 | ## Contributing
97 |
98 | See corresponding [guidelines][4]
99 |
100 | ---
101 |
102 | Copyright (c) 2010-2015 [Brian Quinn][5] and [contributors][6], released under the [New BSD License][3]
103 |
104 | [1]: http://www.fsf.org/licensing/essays/free-sw.html
105 | [2]: https://github.com/spree-contrib/spree_related_products/issues
106 | [3]: https://github.com/spree-contrib/spree_related_products/blob/master/LICENSE.md
107 | [4]: https://github.com/spree-contrib/spree_related_products/blob/master/CONTRIBUTING.md
108 | [5]: https://github.com/BDQ
109 | [6]: https://github.com/spree-contrib/spree_related_products/graphs/contributors
110 |
--------------------------------------------------------------------------------
/app/models/spree/product_decorator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Spree
4 | module ProductDecorator
5 | def self.prepended(base)
6 | base.has_many :relations, -> { order(:position) }, class_name: 'Spree::Relation', as: :relatable
7 | base.has_many :reverse_relations, -> { order(:position) }, class_name: 'Spree::Relation', as: :related_to
8 | base.has_many :relation_types, -> { distinct.reorder(nil) }, class_name: 'Spree::RelationType', through: :relations
9 |
10 | # When a Spree::Product is destroyed, we also want to destroy all
11 | # Spree::Relations "from" it as well as "to" it.
12 | base.after_destroy :destroy_product_relations
13 | base.extend ClassMethods
14 | end
15 |
16 | module ClassMethods
17 | # Returns all the Spree::RelationType's which apply_to this class.
18 | def relation_types
19 | Spree::RelationType.where(applies_to: to_s).order(:name)
20 | end
21 |
22 | # The AREL Relations that will be used to filter the resultant items.
23 | #
24 | # By default this will remove any items which are deleted,
25 | # or not yet available.
26 | #
27 | # You can override this method to fine tune the filter. For example,
28 | # to only return Spree::Product's with more than 2 items in stock, you could
29 | # do the following:
30 | #
31 | # def self.relation_filter
32 | # set = super
33 | # set.where('spree_products.count_on_hand >= 2')
34 | # end
35 | #
36 | # This could also feasibly be overridden to sort the result in a
37 | # particular order, or restrict the number of items returned.
38 | def relation_filter
39 | where('spree_products.deleted_at' => nil)
40 | .where('spree_products.available_on IS NOT NULL')
41 | .where('spree_products.available_on <= ?', Time.now)
42 | .references(self)
43 | end
44 | end
45 |
46 | # Decides if there is a relevant Spree::RelationType related to this class
47 | # which should be returned for this method.
48 | #
49 | # If so, it calls relations_for_relation_type. Otherwise it passes
50 | # it up the inheritance chain.
51 | def method_missing(method, *args)
52 | relation_type = find_relation_type(method)
53 | if relation_type.nil?
54 | super
55 | else
56 | relations_for_relation_type(relation_type)
57 | end
58 | end
59 |
60 | def has_related_products?(relation_method)
61 | find_relation_type(relation_method).present?
62 | end
63 |
64 | def destroy_product_relations
65 | # First we destroy relationships "from" this Product to others.
66 | relations.destroy_all
67 | # Next we destroy relationships "to" this Product.
68 | Spree::Relation.where(related_to_type: self.class.to_s).where(related_to_id: id).destroy_all
69 | end
70 |
71 | private
72 |
73 | def find_relation_type(relation_name)
74 | self.class.relation_types.detect do |rt|
75 | format_name(rt.name) == format_name(relation_name)
76 | end
77 | rescue ActiveRecord::StatementInvalid
78 | # This exception is throw if the relation_types table does not exist.
79 | # And this method is getting invoked during the execution of a migration
80 | # from another extension when both are used in a project.
81 | nil
82 | end
83 |
84 | # Returns all the Products that are related to this record for the given RelationType.
85 | #
86 | # Uses the Relations to find all the related items, and then filters
87 | # them using +Product.relation_filter+ to remove unwanted items.
88 | def relations_for_relation_type(relation_type)
89 | # Find all the relations that belong to us for this RelationType, ordered by position
90 | related_ids = relations.where(relation_type_id: relation_type.id)
91 | .order(:position)
92 | .select(:related_to_id)
93 |
94 | # Construct a query for all these records
95 | result = self.class.where(id: related_ids)
96 |
97 | # Merge in the relation_filter if it's available
98 | result = result.merge(self.class.relation_filter) if relation_filter
99 |
100 | # make sure results are in same order as related_ids array (position order)
101 | result.where(id: related_ids).order(:position) if result.present?
102 |
103 | result
104 | end
105 |
106 | # Simple accessor for the class-level relation_filter.
107 | # Could feasibly be overloaded to filter results relative to this
108 | # record (eg. only higher priced items)
109 | def relation_filter
110 | self.class.relation_filter
111 | end
112 |
113 | def format_name(name)
114 | name.to_s.downcase.tr(' ', '_').pluralize
115 | end
116 | end
117 | end
118 |
119 | ::Spree::Product.prepend(Spree::ProductDecorator)
120 |
--------------------------------------------------------------------------------
/app/models/related_products/spree/product_decorator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module RelatedProducts
4 | module Spree
5 | module ProductDecorator
6 | def self.prepended(base)
7 | base.has_many :relations, -> { order(:position) }, class_name: 'Spree::Relation', as: :relatable
8 | base.has_many :relation_types, -> { reorder(nil).distinct }, class_name: 'Spree::RelationType', through: :relations
9 |
10 | # When a Spree::Product is destroyed, we also want to destroy all
11 | # Spree::Relations "from" it as well as "to" it.
12 | base.after_destroy :destroy_product_relations
13 | base.extend ClassMethods
14 | end
15 |
16 | module ClassMethods
17 | # Returns all the Spree::RelationType's which apply_to this class.
18 | def relation_types
19 | ::Spree::RelationType.where(applies_to: to_s).order(:name)
20 | end
21 |
22 | # The AREL Relations that will be used to filter the resultant items.
23 | #
24 | # By default this will remove any items which are deleted,
25 | # or not yet available.
26 | #
27 | # You can override this method to fine tune the filter. For example,
28 | # to only return Spree::Product's with more than 2 items in stock, you could
29 | # do the following:
30 | #
31 | # def self.relation_filter
32 | # set = super
33 | # set.where('spree_products.count_on_hand >= 2')
34 | # end
35 | #
36 | # This could also feasibly be overridden to sort the result in a
37 | # particular order, or restrict the number of items returned.
38 | def relation_filter
39 | where('spree_products.deleted_at' => nil)
40 | .where('spree_products.available_on IS NOT NULL')
41 | .where('spree_products.available_on <= ?', Time.now)
42 | .references(self)
43 | end
44 | end
45 |
46 | # Decides if there is a relevant Spree::RelationType related to this class
47 | # which should be returned for this method.
48 | #
49 | # If so, it calls relations_for_relation_type. Otherwise it passes
50 | # it up the inheritance chain.
51 | def method_missing(method, *args)
52 | relation_type = find_relation_type(method)
53 | if relation_type.nil?
54 | super
55 | else
56 | relations_for_relation_type(relation_type)
57 | end
58 | end
59 |
60 | def has_related_products?(relation_method)
61 | find_relation_type(relation_method).present?
62 | end
63 |
64 | def destroy_product_relations
65 | # First we destroy relationships "from" this Product to others.
66 | relations.destroy_all
67 | # Next we destroy relationships "to" this Product.
68 | ::Spree::Relation.where(related_to_type: self.class.to_s).where(related_to_id: id).destroy_all
69 | end
70 |
71 | private
72 |
73 | def find_relation_type(relation_name)
74 | self.class.relation_types.detect do |rt|
75 | format_name(rt.name) == format_name(relation_name)
76 | end
77 | rescue ActiveRecord::StatementInvalid
78 | # This exception is throw if the relation_types table does not exist.
79 | # And this method is getting invoked during the execution of a migration
80 | # from another extension when both are used in a project.
81 | nil
82 | end
83 |
84 | # Returns all the Products that are related to this record for the given RelationType.
85 | #
86 | # Uses the Relations to find all the related items, and then filters
87 | # them using +Product.relation_filter+ to remove unwanted items.
88 | def relations_for_relation_type(relation_type)
89 | # Find all the relations that belong to us for this RelationType, ordered by position
90 | related_ids = relations.where(relation_type_id: relation_type.id)
91 | .order(:position)
92 | .select(:related_to_id)
93 |
94 | # Construct a query for all these records
95 | result = self.class.where(id: related_ids)
96 |
97 | # Merge in the relation_filter if it's available
98 | result = result.merge(self.class.relation_filter) if relation_filter
99 |
100 | # make sure results are in same order as related_ids array (position order)
101 | result.where(id: related_ids).order(:position) if result.present?
102 |
103 | result
104 | end
105 |
106 | # Simple accessor for the class-level relation_filter.
107 | # Could feasibly be overloaded to filter results relative to this
108 | # record (eg. only higher priced items)
109 | def relation_filter
110 | self.class.relation_filter
111 | end
112 |
113 | def format_name(name)
114 | name.to_s.downcase.tr(' ', '_').pluralize
115 | end
116 | end
117 | end
118 | end
119 |
120 | ::Spree::Product.prepend(RelatedProducts::Spree::ProductDecorator) if ::Spree::Product.included_modules.exclude?(RelatedProducts::Spree::ProductDecorator)
121 |
--------------------------------------------------------------------------------
/spec/models/spree/product_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe Spree::Product, type: :model do
2 | context 'class' do
3 | describe '.relation_types' do
4 | it 'returns all the RelationTypes in use for this Product' do
5 | relation_type = create(:relation_type)
6 | expect(described_class.relation_types).to include(relation_type)
7 | end
8 | end
9 | end
10 |
11 | context 'relations' do
12 | it { is_expected.to have_many(:relations) }
13 | end
14 |
15 | context 'instance' do
16 | let(:other1) { create(:product) }
17 | let(:other2) { create(:product) }
18 |
19 | before do
20 | @product = create(:product)
21 | @relation_type = create(:relation_type, name: 'Related Products')
22 | end
23 |
24 | describe '.relations' do
25 | it 'has many relations' do
26 | relation1 = create(:relation, relatable: @product, related_to: other1, relation_type: @relation_type)
27 | relation2 = create(:relation, relatable: @product, related_to: other2, relation_type: @relation_type)
28 |
29 | @product.reload
30 | expect(@product.relations).to include(relation1)
31 | expect(@product.relations).to include(relation2)
32 | end
33 |
34 | it 'has many relations for different RelationTypes' do
35 | other_relation_type = Spree::RelationType.new(name: 'Recommended Products')
36 |
37 | relation1 = create(:relation, relatable: @product, related_to: other1, relation_type: @relation_type)
38 | relation2 = create(:relation, relatable: @product, related_to: other1, relation_type: other_relation_type)
39 |
40 | @product.reload
41 | expect(@product.relations).to include(relation1)
42 | expect(@product.relations).to include(relation2)
43 | end
44 | end
45 |
46 | describe 'RelationType finders' do
47 | before do
48 | @relation = create(:relation, relatable: @product, related_to: other1, relation_type: @relation_type)
49 | @product.reload
50 | end
51 |
52 | it 'returns the relevant relations' do
53 | expect(@product.related_products).to include(other1)
54 | end
55 |
56 | it 'recognizes the method with has_related_products?(method)' do
57 | expect(@product.has_related_products?('related_products')).to be_truthy
58 | end
59 |
60 | it 'does not recognize non-existent methods with has_related_products?(method)' do
61 | expect(@product.has_related_products?('unrelated_products')).not_to be_truthy
62 | end
63 |
64 | it 'is the pluralised form of the RelationType name' do
65 | @relation_type.update(name: 'Related Product')
66 | expect(@product.related_products).to include(other1)
67 | end
68 |
69 | it 'does not return relations for another RelationType' do
70 | other_relation_type = Spree::RelationType.new(name: 'Recommended Products')
71 |
72 | create(:relation, relatable: @product, related_to: other1, relation_type: @relation_type)
73 | create(:relation, relatable: @product, related_to: other2, relation_type: other_relation_type)
74 |
75 | @product.reload
76 | expect(@product.related_products).to include(other1)
77 | expect(@product.related_products).not_to include(other2)
78 | end
79 |
80 | it 'does not return Products that are deleted' do
81 | other1.update(deleted_at: Time.now)
82 | expect(@product.related_products).to be_blank
83 | end
84 |
85 | it 'does not return Products that are not yet available' do
86 | other1.update(available_on: Time.now + 1.hour)
87 | expect(@product.related_products).to be_blank
88 | end
89 |
90 | it 'does not return Products where available_on are blank' do
91 | other1.update(available_on: nil)
92 | expect(@product.related_products).to be_blank
93 | end
94 |
95 | it 'returns all results when .relation_filter is nil' do
96 | expect(described_class).to receive(:relation_filter).and_return(nil)
97 | other1.update(available_on: Time.now + 1.hour)
98 | expect(@product.related_products).to include(other1)
99 | end
100 |
101 | context 'with an enhanced Product.relation_filter' do
102 | it 'restricts the filter' do
103 | relation_filter = described_class.relation_filter
104 | expect(described_class).to receive(:relation_filter).at_least(:once).and_return(relation_filter.includes(:master).where('spree_variants.cost_price > 20'))
105 |
106 | other1.master.update(cost_price: 10)
107 | other2.master.update(cost_price: 30)
108 |
109 | create(:relation, relatable: @product, related_to: other2, relation_type: @relation_type)
110 | results = @product.related_products
111 | expect(results).not_to include(other1)
112 | expect(results).to include(other2)
113 | end
114 | end
115 | end
116 | end
117 |
118 | xcontext 'instance when relation_types table is missing' do
119 | it 'method missing should not throw ActiveRecord::StatementInvalid when the spree_relation_types table is missing', with_truncation: true do
120 | described_class.connection.rename_table('spree_relation_types', 'missing_relation_types')
121 | begin
122 | product = described_class.new
123 | expect { product.foo }.to raise_error(NameError)
124 | ensure
125 | described_class.connection.rename_table('missing_relation_types', 'spree_relation_types')
126 | end
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------