├── .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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | <% @relation_types.each do |relation_type| %> 27 | 28 | 29 | 30 | 31 | 35 | 36 | <% end %> 37 | 38 |
<%= Spree.t(:name) %><%= Spree.t(:applies_to) %><%= Spree.t(:description) %>
<%= relation_type.name %><%= relation_type.applies_to %><%= relation_type.description %> 32 | <%= link_to_edit relation_type, no_text: true %> 33 | <%= link_to_delete relation_type, no_text: true %> 34 |
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 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <% product.relations.each do |relation| %> 20 | 21 | 28 | <% if defined? Spree::Frontend %> 29 | 30 | <% else %> 31 | 32 | <% end %> 33 | 34 | 44 | 47 | 48 | <% end %> 49 | 50 |
<%= Spree.t(:name) %><%= Spree.t(:type) %><%= Spree.t(:discount_amount) %>
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 | <%= link_to relation.related_to.name, relation.related_to %><%= link_to relation.related_to.name, admin_product_path(relation.related_to) %><%= relation.relation_type.name %> 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 |
45 | <%= link_to_delete relation, url: admin_product_relation_url(relation.relatable, relation), no_text: true %> 46 |
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 |
27 | 37 | 43 | 54 |
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 | [![Build Status](https://travis-ci.org/spree-contrib/spree_related_products.svg?branch=master)](https://travis-ci.org/spree-contrib/spree_related_products) 4 | [![Code Climate](https://codeclimate.com/github/spree-contrib/spree_related_products/badges/gpa.svg)](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 | --------------------------------------------------------------------------------