Compare commits

...

34 Commits

Author SHA1 Message Date
Rachel Arnould
06d6db5a07 Merge pull request #14075 from gbathree/13688-fix-button-font-consistency
Fix: unify font-family across all .button elements
2026-03-20 11:03:51 +01:00
Rachel Arnould
3f81883bc7 Merge pull request #14061 from mvanhorn/fix/enterprise-user-inline-error-style
Fix inline error style in Add Manager dialog
2026-03-20 11:03:32 +01:00
Rachel Arnould
27be0f6fd1 Merge pull request #13912 from dacook/sourced-variant1-13887
Create linked variants
2026-03-20 10:59:46 +01:00
Greg Austic
032953e7d6 Fix: unify font-family across all .button elements
<button> elements don't inherit font-family from parent by default
in all browsers, causing a visible font mismatch between the
link-based buttons (Back To Store, Back To Website, Cancel Order)
and the button-tag-based Save Changes button on the order
confirmation page.

Add `font-family: inherit` to the base `.button, button` rule so
all button elements use the inherited page font (Roboto). Remove the
now-redundant `font-family: $body-font` from the `.primary` rule.

Fixes #13688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:22:59 -04:00
Matt Van Horn
1878a39188 Fix inline error style in Add Manager dialog to match product list
Add .field class to the email row in the user invitation modal so
the .formError styles (icon, color, font-size) defined in forms.scss
apply consistently with the product list inline errors.

Fixes #13993
2026-03-18 21:46:21 -07:00
David Cook
8e6f1c4e99 Show display name 2026-03-18 14:30:27 +11:00
David Cook
2004934399 Register only necessary elements
This should be more efficient.

Best viewed with whitespace ignored.
2026-03-18 14:30:23 +11:00
David Cook
827ba1990d Ensure changes are tracked on newly added variant
I considered a few ways to do this. Cloned products are done with MutationObserver but it doesn't quite sit right with me. A dedicated controller for newly added rows would provide a good general solution. But do we want yet another controller? I'm not sure. This works and is pretty simple (although it requires a quick loop over _every_ form element.. let's see if we can avoid that.)
2026-03-18 14:03:11 +11:00
David Cook
18fb1cfa74 Rename variant 'owner' to 'hub'
As discussed by team, and using same nomenclature as VariantOverride.
2026-03-11 11:09:13 +11:00
David Cook
e9ce2df5a9 Rename 'source variant' to linked variant (in most places)
There are two types of linked variant associations: source and target, so we need to keep the name there.
But when cloning a variant and retaining a link as source,  we will prefer the general term 'linked variant'. Hopefully this name works well.
2026-03-11 11:09:13 +11:00
David Cook
c165ade4ba Avoid unnecessary save
Actually, the variant factory is still adding an extra save. We should refactor Variant to avoid that.. but the afternoon slump has got me.
2026-03-11 11:09:12 +11:00
David Cook
7e8b3694be Move class instance variable to helper 2026-03-11 11:09:12 +11:00
David Cook
6ee715419a ORder by ID to ensure deterministic results 2026-03-11 11:09:12 +11:00
David Cook
7da6adfe4f Use reference command
For uniformity

Co-authored-by: Maikel <maikel@email.org.au>
2026-03-11 11:09:12 +11:00
David Cook
05c31db46a Remove touch
I think I was just following convention from existing relationships. Perhaps you could argue that a variant is affected by the links added/removed.. but we never look at updated_at so really there's no point at all.
2026-03-11 11:09:12 +11:00
David Cook
666e872ac8 Revert uninentional schema change
I guess my local db is slightly out of sync.
2026-03-11 11:09:12 +11:00
David Cook
de6eb9e281 Avoid useless updated_at column
These records won't be updatable, but it might still be worth tracking when they were created.
2026-03-11 11:09:12 +11:00
David Cook
299ada1220 Refactor: move variant duplication to model
I tried to avoid it but rubocop made me move it. I think maybe it will need to go into a concern or service class later, but hopefully it's ok here for now.
2026-03-11 11:09:12 +11:00
David Cook
5757f086ec Set owner enterprise when creating source variant 2026-03-11 11:09:12 +11:00
David Cook
8955ffe126 AddOwnerToSpreeVariants [migration]
Should existing variants be migrated to have an owner (copied from supplier)? No, because you can change supplier. This concept needs work.
2026-03-11 11:09:09 +11:00
David Cook
b26152cf0e Only show option when you have permission
Preload the allow list once in the controller. This controller was initially set up to avoid instance variables, and pass variables explicitly to the template. That's a good principle, but in practice we have a growing list of variables passed down the chain to multiple partials which is getting cumbersome. I think instance variables have their place after all.
2026-03-11 11:08:50 +11:00
David Cook
78db179ff3 haml-lint:disable ViewLength
TIL we have linting on haml.

I couldn't think of a better way to handle this but would be glad to receive feedback.
2026-03-11 11:08:50 +11:00
David Cook
5fc6d25a69 Display presence of variant link in UI
It's quite ugly. But we will be iterating on this later.
2026-03-11 11:08:50 +11:00
David Cook
b877540f5f Create sourced variant link on clone 2026-03-11 11:08:50 +11:00
copilot-swe-agent[bot]
04c0adf960 Fix source_variants and target_variants associations in Variant model
Co-authored-by: dacook <4188088+dacook@users.noreply.github.com>

Thanks co-pilot for sending me in the right direction.
Would this be neater as a has_and_belongs_to_many? Maybe but I will try to keep moving.
2026-03-11 11:08:50 +11:00
David Cook
1c89e9979e CreateVariantLinks [migration]
Tried using the rails generator, but as usual it was a waste of time becuase it doesn't handle unusual cases.

I found more good guidance from that stackoverflow post:
> why are you worrying about your indexes? Build your app!

Something's not right in the model, see next commit.
2026-03-11 11:08:47 +11:00
David Cook
eba2fbcc30 Create source variants 2026-03-11 11:07:08 +11:00
David Cook
940aa57daf Set up permissions for creating source variants 2026-03-11 11:07:08 +11:00
David Cook
766bedb773 Label this feature as 'beta'
The permission is effectively the feature toggle. Users can choose to use it, but shouldn't expect it all to work perfectly yet.
When it's considered full featured, we just need to update the translation. Hm... I hope that's not too painful.🤞
2026-03-11 11:07:08 +11:00
David Cook
6fe2357ca0 Add enterprise permission create_sourced_variants 2026-03-11 11:07:08 +11:00
David Cook
bd01b5f113 Add 'create sourced variant' option in variant actions menu
For now, we will only be able to create sourced variant from variants that are visible to us (variants that we manage)

    In a later commit I will hide the option if you can't use it
2026-03-11 11:07:08 +11:00
David Cook
e565243ce4 Remove unnecessary before action
Each context has different setup and needs to load the page after setup.
2026-03-11 11:07:08 +11:00
David Cook
0f3b299544 Clean up spec
There's no "COPY OF" product in the spec setup, so we don't need to check that it's not there. (unless maybe we added that to the product factory, but it seems unlikely).

Also we can use helper method.
2026-03-11 11:07:08 +11:00
David Cook
1332051a6e Move specs to relevant file
These tests are about browsing products, not performing actions.

Well, ok there's one about updating, which should probably go in the update file. But hey this is better than before.
And admittedly the "Actions" file covers three different things, not just the actions menu. shrug.
2026-03-11 11:07:07 +11:00
26 changed files with 543 additions and 132 deletions

View File

@@ -6,6 +6,7 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
'manage_products'
'edit_profile'
'create_variant_overrides'
'create_linked_variants'
]
constructor: ->
@@ -30,3 +31,4 @@ angular.module("ofn.admin").factory 'EnterpriseRelationships', ($http, enterpris
when "manage_products" then t('js.services.manage_products')
when "edit_profile" then t('js.services.edit_profile')
when "create_variant_overrides" then t('js.services.add_products_to_inventory')
when "create_linked_variants" then t('js.services.create_linked_variants')

View File

@@ -107,6 +107,33 @@ module Admin
end
end
# Clone a variant, retaining a link to the "source"
def create_linked_variant
linked_variant = Spree::Variant.find(params[:variant_id])
product_index = params[:product_index]
authorize! :create_linked_variant, linked_variant
status = :ok
begin
variant = linked_variant.create_linked_variant(spree_current_user)
flash.now[:success] = t('.success')
variant_index = "-#{variant.id}"
rescue ActiveRecord::RecordInvalid
flash.now[:error] = variant.errors.full_messages.to_sentence
status = :unprocessable_entity
variant_index = "-1" # Create a unique-enough index
end
respond_with do |format|
format.turbo_stream {
locals = { linked_variant:, variant:, product_index:, variant_index:,
producer_options:, category_options: categories, tax_category_options: }
render :create_linked_variant, status:, locals:
}
end
end
def index_url(params)
"/admin/products?#{params.to_query}" # todo: fix routing so this can be automaticly generated
end

View File

@@ -47,5 +47,10 @@ module Admin
def variant_tag_enabled?(user)
feature?(:variant_tag, user) || feature?(:variant_tag, *user.enterprises)
end
def allowed_source_producers
@allowed_source_producers ||= OpenFoodNetwork::Permissions.new(spree_current_user)
.enterprises_granting_linked_variants
end
end
end

View File

@@ -202,7 +202,7 @@ module Spree
def add_product_management_abilities(user)
# Enterprise User can only access products that they are a supplier for
can [:create], Spree::Product
# An enterperprise user can change a product if they are supplier of at least
# An enterprise user can change a product if they are supplier of at least
# one of the product's associated variants
can [:admin, :read, :index, :update,
:seo, :group_buy_options,
@@ -214,7 +214,16 @@ module Spree
)
end
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone], :products_v3
# An enterprise user can clone if they have been granted permission to the source variant.
# Technically I'd call this permission clone_linked_variant, but it would be less confusing to
# use the same name as everywhere else.
can [:create_linked_variant], Spree::Variant do |variant|
OpenFoodNetwork::Permissions.new(user).
enterprises_granting_linked_variants.include? variant.supplier
end
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone,
:create_linked_variant], :products_v3
can [:create], Spree::Variant
can [:admin, :index, :read, :edit,

View File

@@ -40,6 +40,7 @@ module Spree
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', optional: false
belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true, optional: false
belongs_to :supplier, class_name: 'Enterprise', optional: false, touch: true
belongs_to :hub, class_name: 'Enterprise', optional: true
delegate :name, :name=, :description, :description=, :meta_keywords, to: :product
@@ -72,6 +73,15 @@ module Spree
has_many :semantic_links, as: :subject, dependent: :delete_all
has_many :supplier_properties, through: :supplier, source: :properties
# Linked variants: I may have one or many sources.
has_many :variant_links_as_target, class_name: 'VariantLink', foreign_key: :target_variant_id,
dependent: :delete_all, inverse_of: :target_variant
has_many :source_variants, through: :variant_links_as_target, source: :source_variant
# I may also have one more many targets.
has_many :variant_links_as_source, class_name: 'VariantLink', foreign_key: :source_variant_id,
dependent: :delete_all, inverse_of: :source_variant
has_many :target_variants, through: :variant_links_as_source, source: :target_variant
localize_number :price, :weight
validates_lengths_from_database
@@ -263,6 +273,24 @@ module Spree
@on_hand_desired = ActiveModel::Type::Integer.new.cast(val)
end
# Clone this variant, retaining a 'source' link to it
def create_linked_variant(user)
# Hub owner is my enterprise which has permission to create variant sourced from that supplier
hub_id = EnterpriseRelationship.permitted_by(supplier).permitting(user.enterprises)
.with_permission(:create_linked_variants)
.pick(:child_id)
dup.tap do |variant|
variant.price = price
variant.source_variants = [self]
variant.stock_items << Spree::StockItem.new(variant:)
variant.hub_id = hub_id
variant.on_demand = on_demand
variant.on_hand = on_hand
variant.save!
end
end
private
def check_currency

View File

@@ -0,0 +1,6 @@
# frozen_string_literal: true
class VariantLink < ApplicationRecord
belongs_to :source_variant, class_name: 'Spree::Variant'
belongs_to :target_variant, class_name: 'Spree::Variant'
end

View File

@@ -11,9 +11,10 @@
-# Filter out variant a user has not permission to update, but keep variant with no supplier
- next if variant.supplier.present? && !allowed_producers.include?(variant.supplier)
= form.fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |variant_form|
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper", 'data-new-record': variant.new_record? ? "true" : false }
= render partial: 'variant_row', locals: { variant:, f: variant_form, category_options:, tax_category_options:, producer_options: }
= render partial: 'variant_row', locals: { variant:, f: variant_form, product_index:, category_options:, tax_category_options:, producer_options: }
= form.fields_for("products][#{product_index}][variants_attributes][NEW_RECORD", prepare_new_variant(product, producer_options)) do |new_variant_form|
%template{ 'data-nested-form-target': "template" }

View File

@@ -1,7 +1,10 @@
-# locals: (variant:, f:, category_options:, tax_category_options:, producer_options:)
-# haml-lint:disable ViewLength (This file is big, but doesn't make sense to split up at this point)
-# locals: (variant:, f:, product_index: nil, category_options:, tax_category_options:, producer_options:)
- method_on_demand, method_on_hand = variant.new_record? ? [:on_demand_desired, :on_hand_desired ]: [:on_demand, :on_hand]
%td.col-image
-# empty
- variant.source_variants.each do |source_variant|
= content_tag(:span, "🔗", title: t('admin.products_page.variant_row.sourced_from', source_name: source_variant.display_name, source_id: source_variant.id, hub_name: variant.hub&.name))
%td.col-name.field.naked_inputs
= f.hidden_field :id
= f.text_field :display_name, 'aria-label': t('admin.products_page.columns.name'), placeholder: variant.product.name
@@ -88,6 +91,10 @@
= render(VerticalEllipsisMenuComponent.new) do
- if variant.persisted?
= link_to t('admin.products_page.actions.edit'), edit_admin_product_variant_path(variant.product, variant)
- if allowed_source_producers.include?(variant.supplier)
= link_to t('admin.products_page.actions.create_linked_variant'), admin_create_linked_variant_path(variant_id: variant.id, product_index:), 'data-turbo-method': :post
- if variant.product.variants.size > 1
%a{ "data-controller": "modal-link", "data-action": "click->modal-link#setModalDataSetOnConfirm click->modal-link#open",
"data-modal-link-target-value": "variant-delete-modal", "class": "delete",

View File

@@ -0,0 +1,16 @@
-# locals: (variant:, linked_variant:, product_index:, variant_index:, producer_options:, category_options:, tax_category_options:)
-# Pre-render the form, because you can't do it inside turbo stream block
- variant_row = nil
- fields_for("products][#{product_index}][variants_attributes", variant, index: variant_index) do |f|
- variant_row = render(partial: 'variant_row', formats: :html,
locals: { f:,
variant:,
producer_options:,
category_options:,
tax_category_options:})
= turbo_stream.after dom_id(linked_variant) do
%tr.condensed{ id: dom_id(variant), 'data-controller': "variant", 'class': "nested-form-wrapper slide-in",'data-variant-bulk-form-outlet': "#products-form"}
= variant_row
= turbo_stream.append "flashes" do
= render(partial: 'admin/shared/flashes', locals: { flashes: flash })

View File

@@ -6,7 +6,7 @@
%p= t ".description"
%fieldset.no-border-top.no-border-bottom
.row
.row.field
= f.label :email, t(:email)
= f.email_field :email, placeholder: t('.eg_email_address'), data: { controller: "select-user" }, inputmode: "email", autocomplete: "off"
= f.error_message_on :email

View File

@@ -44,12 +44,17 @@ export default class BulkFormController extends Controller {
}
// Register any new elements (may be called by another controller after dynamically adding fields)
registerElements() {
const registeredElements = Object.values(this.recordElements).flat();
// Select only elements that haven't been registered yet
const newElements = Array.from(this.form.elements).filter(
(n) => !registeredElements.includes(n),
);
// May be called with array of elements to register, otherwise finds all un-registered elements.
registerElements(eventOrElements = null) {
let newElements;
if (Array.isArray(eventOrElements)) {
newElements = eventOrElements;
} else {
const registeredElements = Object.values(this.recordElements).flat();
// Select only elements that haven't been registered yet
newElements = Array.from(this.form.elements).filter((n) => !registeredElements.includes(n));
}
this.#registerElements(newElements);
}

View File

@@ -4,6 +4,8 @@ import OptionValueNamer from "js/services/option_value_namer";
// Dynamically update related variant fields
//
export default class VariantController extends Controller {
static outlets = ["bulk-form"];
connect() {
// idea: create a helper that includes a nice getter/setter for Rails model attr values, just pass it the attribute name.
// It could automatically find (and cache a ref to) each dom element and get/set the values.
@@ -40,6 +42,12 @@ export default class VariantController extends Controller {
// on display_as changed; update unit_to_display
// TODO: optimise to avoid unnecessary OptionValueNamer calc
this.displayAs.addEventListener("input", this.#updateUnitDisplay.bind(this), { passive: true });
// Register with bulk products form to listen for changes. Used when dynamically appending variants.
if (this.hasBulkFormOutlet) {
const formElements = this.element.querySelectorAll("input, select, textarea, button");
this.bulkFormOutlet.registerElements(formElements);
}
}
disconnect() {

View File

@@ -54,6 +54,7 @@
.button, button {
@include border-radius(0.5em);
font-family: inherit;
outline: none;
&.x-small {
@@ -65,7 +66,6 @@
}
.button.primary, button.primary {
font-family: $body-font;
background: $orange-450;
color: white;
}

View File

@@ -717,8 +717,11 @@ en:
delete: Delete
remove: Remove
preview: Preview
create_linked_variant: Create linked variant
image:
edit: Edit
variant_row:
sourced_from: "Sourced from: %{source_name} (%{source_id}); Hub: %{hub_name}"
product_preview:
product_preview: Product preview
shop_tab: Shop
@@ -1098,6 +1101,8 @@ en:
clone:
success: Successfully cloned the product
error: Unable to clone the product
create_linked_variant:
success: "Successfully created linked variant"
tag_rules:
rules_per_tag:
one: "%{tag} has 1 rule"
@@ -3901,6 +3906,7 @@ en:
manage_products: "manage products"
edit_profile: "edit profile"
add_products_to_inventory: "add products to inventory"
create_linked_variants: "create linked variants [BETA]"
resources:
could_not_delete_customer: 'Could not delete customer'
product_import:

View File

@@ -82,6 +82,7 @@ Openfoodnetwork::Application.routes.draw do
delete 'products_v3/:id', to: 'products_v3#destroy', as: 'product_destroy'
delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant'
post 'clone/:id', to: 'products_v3#clone', as: 'clone_product'
post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant'
resources :product_preview, only: [:show]
resources :variant_overrides do

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateVariantLinks < ActiveRecord::Migration[7.1]
def change
# Create a join table to join two variants. One is the source of the other.
# Primary key index ensures uniqueness and assists querying. target_variant_id is the most
# likely subject and so is first in the index.
# An additional index for source_variant is also included because it may be helpful
# (https://stackoverflow.com/questions/10790518/best-sql-indexes-for-join-table).
create_table :variant_links, primary_key: [:target_variant_id, :source_variant_id] do |t|
t.integer :source_variant_id, null: false, index: true
t.integer :target_variant_id, null: false
t.datetime :created_at, null: false
end
add_foreign_key :variant_links, :spree_variants, column: :source_variant_id
add_foreign_key :variant_links, :spree_variants, column: :target_variant_id
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddHubToSpreeVariants < ActiveRecord::Migration[7.1]
def change
add_reference :spree_variants, :hub, foreign_key: { to_table: :enterprises }
end
end

View File

@@ -1009,6 +1009,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
t.bigint "supplier_id"
t.float "variant_unit_scale"
t.string "variant_unit_name", limit: 255
t.bigint "hub_id"
t.index ["hub_id"], name: "index_spree_variants_on_hub_id"
t.index ["primary_taxon_id"], name: "index_spree_variants_on_primary_taxon_id"
t.index ["product_id"], name: "index_variants_on_product_id"
t.index ["shipping_category_id"], name: "index_spree_variants_on_shipping_category_id"
@@ -1113,6 +1115,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
t.datetime "updated_at", precision: nil, null: false
end
create_table "variant_links", primary_key: ["target_variant_id", "source_variant_id"], force: :cascade do |t|
t.integer "source_variant_id", null: false
t.integer "target_variant_id", null: false
t.datetime "created_at", null: false
t.index ["source_variant_id"], name: "index_variant_links_on_source_variant_id"
end
create_table "variant_overrides", id: :serial, force: :cascade do |t|
t.integer "variant_id", null: false
t.integer "hub_id", null: false
@@ -1262,6 +1271,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
add_foreign_key "spree_tax_rates", "spree_zones", column: "zone_id", name: "spree_tax_rates_zone_id_fk"
add_foreign_key "spree_users", "spree_addresses", column: "bill_address_id", name: "spree_users_bill_address_id_fk"
add_foreign_key "spree_users", "spree_addresses", column: "ship_address_id", name: "spree_users_ship_address_id_fk"
add_foreign_key "spree_variants", "enterprises", column: "hub_id"
add_foreign_key "spree_variants", "enterprises", column: "supplier_id"
add_foreign_key "spree_variants", "spree_products", column: "product_id", name: "spree_variants_product_id_fk"
add_foreign_key "spree_variants", "spree_shipping_categories", column: "shipping_category_id"
@@ -1278,6 +1288,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_06_015040) do
add_foreign_key "subscriptions", "spree_payment_methods", column: "payment_method_id", name: "subscriptions_payment_method_id_fk"
add_foreign_key "subscriptions", "spree_shipping_methods", column: "shipping_method_id", name: "subscriptions_shipping_method_id_fk"
add_foreign_key "tag_rules", "enterprises"
add_foreign_key "variant_links", "spree_variants", column: "source_variant_id"
add_foreign_key "variant_links", "spree_variants", column: "target_variant_id"
add_foreign_key "variant_overrides", "enterprises", column: "hub_id", name: "variant_overrides_hub_id_fk"
add_foreign_key "variant_overrides", "spree_variants", column: "variant_id", name: "variant_overrides_variant_id_fk"
add_foreign_key "vouchers", "enterprises"

View File

@@ -86,6 +86,10 @@ module OpenFoodNetwork
managed_and_related_enterprises_granting :manage_products
end
def enterprises_granting_linked_variants
related_enterprises_granting :create_linked_variants
end
def manages_one_enterprise?
@user.enterprises.length == 1
end

View File

@@ -16,3 +16,4 @@ describe "enterprise relationships", ->
expect(EnterpriseRelationships.permission_presentation("manage_products")).toEqual "manage products"
expect(EnterpriseRelationships.permission_presentation("edit_profile")).toEqual "edit profile"
expect(EnterpriseRelationships.permission_presentation("create_variant_overrides")).toEqual "add products to inventory"
expect(EnterpriseRelationships.permission_presentation("create_linked_variants")).toEqual "create linked variants [BETA]"

View File

@@ -364,6 +364,19 @@ RSpec.describe Spree::Ability do
for: p2.variants.first)
end
describe "create_linked_variant" do
it "should not be able to create linked variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
end
it "should be able to create linked variant when granted permission" do
create(:enterprise_relationship, parent: s_related, child: s1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
end
end
it "should not be able to access admin actions on orders" do
is_expected.not_to have_ability([:admin], for: Spree::Order)
end
@@ -729,6 +742,19 @@ RSpec.describe Spree::Ability do
it "can request permitted enterprise fees for an order cycle" do
is_expected.to have_ability([:for_order_cycle], for: EnterpriseFee)
end
describe "create_linked_variant" do
it "should not be able to create linked variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p_related.variants.first)
end
it "should be able to create linked variant when granted permission" do
create(:enterprise_relationship, parent: s_related, child: d1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p_related.variants.first)
end
end
end
context 'Order Cycle co-ordinator, distributor enterprise manager' do
@@ -804,6 +830,19 @@ RSpec.describe Spree::Ability do
it "has the ability to manage vouchers" do
is_expected.to have_ability([:admin, :create], for: Voucher)
end
describe "create_linked_variant for own enterprise" do
it "should not be able to create own sourced variant without permission" do
is_expected.not_to have_ability([:create_linked_variant], for: p1.variants.first)
end
it "should be able to create own sourced variant when granted self permission" do
create(:enterprise_relationship, parent: s1, child: s1,
permissions_list: [:create_linked_variants])
is_expected.to have_ability([:create_linked_variant], for: p1.variants.first)
end
end
end
context 'enterprise owner' do

View File

@@ -8,6 +8,7 @@ RSpec.describe Spree::Variant do
it { is_expected.to have_many :semantic_links }
it { is_expected.to belong_to(:product).required }
it { is_expected.to belong_to(:supplier).required }
it { is_expected.to belong_to(:hub).optional }
it { is_expected.to have_many(:inventory_units) }
it { is_expected.to have_many(:line_items) }
it { is_expected.to have_many(:stock_items) }
@@ -20,6 +21,9 @@ RSpec.describe Spree::Variant do
it { is_expected.to have_many(:inventory_items) }
it { is_expected.to have_many(:supplier_properties).through(:supplier) }
it { is_expected.to have_many(:source_variants).through(:variant_links_as_target) }
it { is_expected.to have_many(:target_variants).through(:variant_links_as_source) }
describe "shipping category" do
it "sets a shipping category if none provided" do
variant = build(:variant, shipping_category: nil)
@@ -1001,4 +1005,30 @@ RSpec.describe Spree::Variant do
expect(variant.unit_presentation).to eq "My display"
end
end
describe "#create_linked_variant" do
let(:user) { create(:user, enterprises: [enterprise]) }
let(:supplier) { variant.supplier }
let(:enterprise) { create(:enterprise) }
context "with create_linked_variants permissions on supplier" do
let!(:enterprise_relationship) {
create(:enterprise_relationship,
parent: supplier,
child: enterprise,
permissions_list: [:create_linked_variants])
}
let(:variant) { create(:variant, price: 10.95, on_demand: false, on_hand: 5) }
it "clones the variant, retaining a link to the source" do
linked_variant = variant.create_linked_variant(user)
expect(linked_variant.source_variants).to eq [variant]
expect(linked_variant.hub).to eq enterprise
expect(linked_variant.price).to eq 10.95
expect(linked_variant.on_demand).to eq false
expect(linked_variant.on_hand).to eq 5
end
end
end
end

View File

@@ -59,4 +59,71 @@ RSpec.describe "Admin::ProductsV3" do
expect(response).to redirect_to('/unauthorized')
end
end
describe "POST /admin/products/create_linked_variant" do
let(:enterprise) { create(:supplier_enterprise) }
let(:user) { create(:user, enterprises: [enterprise]) }
let(:supplier) { create(:supplier_enterprise) }
let(:variant) { create(:variant, display_name: "Original variant", supplier: supplier) }
before do
sign_in user
end
it "checks for permission" do
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to redirect_to('/unauthorized')
}.not_to change { variant.product.variants.count }
end
context "With create_linked_variants permissions on supplier" do
let!(:enterprise_relationship) {
create(:enterprise_relationship,
parent: supplier,
child: enterprise,
permissions_list: [:create_linked_variants])
}
it "clones the variant, retaining link as source" do
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to have_http_status(:ok)
expect(response.body).to match "Original variant" # cloned variant name
}.to change { variant.product.variants.count }.by(1)
new_variant = variant.product.variants.order(:id).last
# The new variant is a target of the original. It is a "sourced" variant.
expect(variant.target_variants.first).to eq new_variant
# The new variant's source is the original
expect(new_variant.source_variants.first).to eq variant
end
context "and I'm also owner of another enterprise" do
let!(:enterprise2) { create(:enterprise) }
let(:user) { create(:user, enterprises: [enterprise, enterprise2]) }
it "clones the variant, owned by my enterprise that has permission" do
enterprise2.owner = user
params = { variant_id: variant.id, product_index: 1 }
expect {
post(admin_create_linked_variant_path, as: :turbo_stream, params:)
expect(response).to have_http_status(:ok)
}.to change { variant.product.variants.count }.by(1)
# The new variant is owned by my enterprise that has permission, not the other one
new_variant = variant.product.variants.order(:id).last
expect(new_variant.hub).to eq enterprise
end
end
end
end
end

View File

@@ -47,18 +47,23 @@ create(:enterprise)
uncheck 'to manage products'
check 'to edit profile'
check 'to add products to inventory'
check 'to create linked variants'
select2_select 'Two', from: 'enterprise_relationship_child_id'
click_button 'Create'
# Wait for row to appear since have_relationship doesn't wait
expect(page).to have_selector 'tr', count: 2
# Permissions appear.. in a different order for some reason.
expect_relationship_with_permissions e1, e2,
['to add to order cycle',
'to add products to inventory', 'to edit profile']
'to create linked variants [BETA]',
'to add products to inventory',
'to edit profile']
er = EnterpriseRelationship.where(parent_id: e1, child_id: e2).first
expect(er).to be_present
expect(er.permissions.map(&:name)).to match_array ['add_to_order_cycle', 'edit_profile',
'create_variant_overrides']
'create_variant_overrides',
'create_linked_variants']
end
it "attempting to create a relationship with invalid data" do

View File

@@ -2,7 +2,7 @@
require "system_helper"
RSpec.describe 'As an enterprise user, I can manage my products' do
RSpec.describe 'As an enterprise user, I can perform actions on the products screen' do
include AdminHelper
include WebHelper
include AuthenticationHelper
@@ -19,18 +19,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
let(:categories_search_selector) { 'input[placeholder="Select category"]' }
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
describe "with no products" do
before { visit admin_products_url }
it "can see the new product page" do
expect(page).to have_content "Bulk Edit Products"
expect(page).to have_text "No products found"
# displays buttons to add products with the correct links
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
expect(page).to have_link(class: "button", text: "Import multiple products",
href: admin_product_import_path)
end
end
describe "column selector" do
let!(:product) { create(:simple_product) }
@@ -105,8 +93,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
describe "columns"
describe "Changing producers, category and tax category" do
let!(:variant_a1) {
product_a.variants.first.tap{ |v|
@@ -260,24 +246,24 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
describe "Cloning product" do
it "shows the cloned product on page when clicked on the cloned option" do
# TODO, variant supplier missing, needs to be copied from variant and not product
within "table.products" do
# Gather input values, because page.content doesn't include them.
input_content = page.find_all('input[type=text]').map(&:value).join
# Products does not include the cloned product.
expect(input_content).not_to match /COPY OF Apples/
end
click_product_clone "Apples"
expect(page).to have_content "Successfully cloned the product"
within "table.products" do
# Gather input values, because page.content doesn't include them.
input_content = page.find_all('input[type=text]').map(&:value).join
# Product list includes the cloned product.
expect(all_input_values).to match /COPY OF Apples/
# Products include the cloned product.
expect(input_content).to match /COPY OF Apples/
# And I can perform actions on the new product
within row_containing_name "COPY OF Apples" do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Edit"
expect(page).to have_link "Clone"
# expect(page).to have_link "Delete" # it's not a proper link :/
fill_in "Name", with: "My copy of Apples"
end
click_button "Save changes"
end
end
end
@@ -298,6 +284,88 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
describe "Create linked variant" do
let!(:variant) {
create(:variant, display_name: "My box", supplier: producer)
}
let!(:other_producer) { create(:supplier_enterprise) }
let!(:other_variant) {
create(:variant, display_name: "My friends box", supplier: other_producer)
}
let!(:enterprise_relationship) {
# Other producer grants me access to manage their variant
create(:enterprise_relationship, parent: other_producer, child: producer,
permissions_list: [:manage_products])
}
context "with create_linked_variants permission for my, and other's variants" do
it "creates a linked variant" do
create(:enterprise_relationship, parent: producer, child: producer,
permissions_list: [:create_linked_variants])
enterprise_relationship.permissions.create! name: :create_linked_variants
visit admin_products_url
# Check my own variant
within row_containing_name("My box") do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Create linked variant"
end
# Create linked variant sourced from my friend
within row_containing_name("My friends box") do
page.find(".vertical-ellipsis-menu").click
click_link "Create linked variant"
end
expect(page).to have_content "Successfully created linked variant"
within "table.products" do
# There are now two copies
expect(all_input_values).to match /My friends box.*My friends box/
# One of them is designated as a linked variant
expect(page).to have_content "🔗"
last_box = page.all(row_containing_name("My friends box")).last
# Close action menu (shouldn't need this, it should close itself)
last_box.click
# And I can perform actions on the new product
within last_box do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Edit"
# expect(page).to have_link "Clone" # tofix: menu is partially obscured
# expect(page).to have_link "Delete" # it's not a proper link
fill_in "Name", with: "My copy of Apples"
end
click_button "Save changes"
# initially obscured by the previous message, then disappears before capybara sees it.
# expect(page).to have_content "Changes saved"
end
end
end
context "without create_linked_variants permission" do
it "does not show the option in the menu" do
visit admin_products_url
within row_containing_name("My box") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
within row_containing_name("My friends box") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
end
end
end
describe "delete" do
let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") }
let(:delete_option_selector) { "a[data-controller='modal-link'].delete" }
@@ -527,90 +595,6 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
context "as an enterprise manager" do
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
let!(:product_supplied_permitted) {
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
}
let(:product_supplied_inactive) {
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
}
let!(:supplier_permitted_relationship) do
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
permissions_list: [:manage_products])
end
before do
enterprise_user = create(:user)
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
login_as enterprise_user
end
it "shows only products that I supply" do
visit spree.admin_products_path
# displays permitted product list only
expect(page).to have_selector row_containing_name(product_supplied.name)
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
end
it "shows only suppliers that I manage or have permission to" do
visit spree.admin_products_path
within row_containing_placeholder(product_supplied.name) do
expect(page).to have_select(
'_products_0_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_managed1.name
)
end
within row_containing_placeholder(product_supplied_permitted.name) do
expect(page).to have_select(
'_products_1_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_permitted.name
)
end
end
it "shows inactive products that I supply" do
product_supplied_inactive
visit spree.admin_products_path
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
end
it "allows me to update a product" do
visit spree.admin_products_path
within row_containing_name(product_supplied.name) do
fill_in "Name", with: "Pommes"
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
expect(page).to have_selector row_containing_name("Pommes")
end
end
def open_action_menu
page.find(".vertical-ellipsis-menu").click
end

View File

@@ -2,13 +2,13 @@
require "system_helper"
RSpec.describe 'As an enterprise user, I can manage my products' do
RSpec.describe 'As an enterprise user, I can browse my products' do
include AdminHelper
include WebHelper
include AuthenticationHelper
include FileHelper
let(:producer) { create(:supplier_enterprise) }
let(:producer) { create(:supplier_enterprise, name: "My Enterprise") }
let(:user) { create(:user, enterprises: [producer]) }
before do
@@ -19,15 +19,25 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
let(:categories_search_selector) { 'input[placeholder="Search for categories"]' }
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
describe "with no products" do
before { visit admin_products_url }
it "can see the new product page" do
expect(page).to have_content "Bulk Edit Products"
expect(page).to have_text "No products found"
# displays buttons to add products with the correct links
expect(page).to have_link(class: "button", text: "New Product", href: "/admin/products/new")
expect(page).to have_link(class: "button", text: "Import multiple products",
href: admin_product_import_path)
end
end
describe "listing" do
let!(:p1) { create(:product, name: "Product1") }
let!(:p2) { create(:product, name: "Product2") }
before do
visit admin_products_url
end
it "displays a list of products" do
visit admin_products_path
within ".products" do
# displays table header
expect(page).to have_selector "th", text: "Name"
@@ -129,6 +139,34 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
expect(page).to have_select "variant_unit_with_scale", selected: "Items"
expect(page).to have_field "variant_unit_name", with: "packet"
end
context "with sourced variant" do
let(:source_producer) { create(:supplier_enterprise) }
let(:p3) { create(:product, name: "Product3", supplier_id: source_producer.id) }
let!(:v3_source) { p3.variants.first }
let!(:v3_sourced) {
create(:variant, display_name: "Variant3-sourced", product: p3, supplier: source_producer,
hub: producer)
}
let!(:enterprise_relationship) {
# Other producer grants me access to manage their variant
create(:enterprise_relationship, parent: source_producer, child: producer,
permissions_list: [:manage_products])
}
before do
v3_sourced.source_variants << v3_source
visit admin_products_url
end
it "shows sourced variant with indicator" do
within row_containing_name("Variant3-sourced") do
expect(page).to have_selector 'span[title*="Sourced from: "]'
expect(page).to have_selector 'span[title*="Hub: My Enterprise"]'
end
end
end
end
describe "sorting" do
@@ -463,4 +501,88 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
end
end
end
context "as an enterprise manager" do
let(:supplier_managed1) { create(:supplier_enterprise, name: 'Supplier Managed 1') }
let(:supplier_managed2) { create(:supplier_enterprise, name: 'Supplier Managed 2') }
let(:supplier_unmanaged) { create(:supplier_enterprise, name: 'Supplier Unmanaged') }
let(:supplier_permitted) { create(:supplier_enterprise, name: 'Supplier Permitted') }
let(:distributor_managed) { create(:distributor_enterprise, name: 'Distributor Managed') }
let(:distributor_unmanaged) { create(:distributor_enterprise, name: 'Distributor Unmanaged') }
let!(:product_supplied) { create(:product, supplier_id: supplier_managed1.id, price: 10.0) }
let!(:product_not_supplied) { create(:product, supplier_id: supplier_unmanaged.id) }
let!(:product_supplied_permitted) {
create(:product, name: 'Product Permitted', supplier_id: supplier_permitted.id, price: 10.0)
}
let(:product_supplied_inactive) {
create(:product, supplier_id: supplier_managed1.id, price: 10.0)
}
let!(:supplier_permitted_relationship) do
create(:enterprise_relationship, parent: supplier_permitted, child: supplier_managed1,
permissions_list: [:manage_products])
end
before do
enterprise_user = create(:user)
enterprise_user.enterprise_roles.build(enterprise: supplier_managed1).save
enterprise_user.enterprise_roles.build(enterprise: supplier_managed2).save
enterprise_user.enterprise_roles.build(enterprise: distributor_managed).save
login_as enterprise_user
end
it "shows only products that I supply" do
visit spree.admin_products_path
# displays permitted product list only
expect(page).to have_selector row_containing_name(product_supplied.name)
expect(page).to have_selector row_containing_name(product_supplied_permitted.name)
expect(page).not_to have_selector row_containing_name(product_not_supplied.name)
end
it "shows only suppliers that I manage or have permission to" do
visit spree.admin_products_path
within row_containing_placeholder(product_supplied.name) do
expect(page).to have_select(
'_products_0_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_managed1.name
)
end
within row_containing_placeholder(product_supplied_permitted.name) do
expect(page).to have_select(
'_products_1_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_permitted.name
)
end
end
it "shows inactive products that I supply" do
product_supplied_inactive
visit spree.admin_products_path
expect(page).to have_selector row_containing_name(product_supplied_inactive.name)
end
it "allows me to update a product" do
visit spree.admin_products_path
within row_containing_name(product_supplied.name) do
fill_in "Name", with: "Pommes"
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
expect(page).to have_selector row_containing_name("Pommes")
end
end
end