diff --git a/admin/app/components/solidus_admin/stores/form/component.html.erb b/admin/app/components/solidus_admin/stores/form/component.html.erb index f22c6c5328b..a82f303ec16 100644 --- a/admin/app/components/solidus_admin/stores/form/component.html.erb +++ b/admin/app/components/solidus_admin/stores/form/component.html.erb @@ -11,6 +11,15 @@ <% end %> <% end %> + <%= render component('ui/panel').new(title: t('.address')) do |panel| %> + <% panel.with_section do %> + <%= render component('ui/forms/address').new( + addressable: address, + form_field_name: "store[address_attributes]", + excludes: address_excludes + ) %> + <% end %> + <% end %> <%= render component('ui/panel').new(title: t('.localization')) do |panel| %> <% panel.with_section(class: "flex flex-col gap-4") do %>
diff --git a/admin/app/components/solidus_admin/stores/form/component.rb b/admin/app/components/solidus_admin/stores/form/component.rb index 7ff6fbb4198..0d849e46bad 100644 --- a/admin/app/components/solidus_admin/stores/form/component.rb +++ b/admin/app/components/solidus_admin/stores/form/component.rb @@ -9,6 +9,16 @@ def initialize(store:, id:, url:) @url = url end + def address + @store.address || Spree::Address.new(country: Spree::Country.find_by(iso: Spree::Config.default_country_iso)) + end + + def address_excludes + excludes = [:email, :street_contd] + excludes << :phone unless Spree::Config.address_requires_phone + excludes + end + def available_locales Spree.i18n_available_locales.map do |locale| [I18n.t("spree.i18n.this_file_language", locale: locale, default: locale.to_s, fallback: false), locale] diff --git a/admin/app/components/solidus_admin/stores/form/component.yml b/admin/app/components/solidus_admin/stores/form/component.yml index 9f8ce0ff5b5..72fdf85aadf 100644 --- a/admin/app/components/solidus_admin/stores/form/component.yml +++ b/admin/app/components/solidus_admin/stores/form/component.yml @@ -1,4 +1,5 @@ en: + address: Address localization: Localization plugins: install: Install diff --git a/admin/app/controllers/solidus_admin/stores_controller.rb b/admin/app/controllers/solidus_admin/stores_controller.rb index d86d03f6e97..c6fcba3cd5b 100644 --- a/admin/app/controllers/solidus_admin/stores_controller.rb +++ b/admin/app/controllers/solidus_admin/stores_controller.rb @@ -55,8 +55,12 @@ def permitted_resource_params :mail_from_address, :default_currency, :cart_tax_country_iso, - available_locales: [] - ) + available_locales: [], + address_attributes: Spree::PermittedAttributes.address_attributes + ).tap do |store_params| + without_address_parameter = store_params[:address_attributes]&.except(:country_id, :state_id, :reverse_charge_status)&.compact_blank&.empty? + store_params.delete(:address_attributes) if without_address_parameter + end end end end diff --git a/admin/spec/features/stores_spec.rb b/admin/spec/features/stores_spec.rb index 40ea5a9f4cc..7382909693d 100644 --- a/admin/spec/features/stores_spec.rb +++ b/admin/spec/features/stores_spec.rb @@ -83,6 +83,7 @@ available_locales: %w[en]) create(:country, iso: "US") + create(:country, iso: "DE", states_required: false) visit "/admin/stores" click_on "B2C Store" expect(page).to be_axe_clean @@ -111,6 +112,28 @@ expect(solidus_select_control("Default Currency")).to have_content("USD") expect(solidus_select_control("Tax Country")).to have_content("United States") end + + it "updates store address" do + expect(page).not_to have_field("Email", exact: true) + + fill_in "Name", with: "B2C Store Inc." + fill_in "Street Address", with: "1 Store Street" + fill_in "City", with: "Storetown" + fill_in "Zip Code", with: "12345" + fill_in "Phone", with: "555-555-0199" + solidus_select("Germany", from: "Country") + + within("header") { click_on "Save" } + + expect(page).to have_content("Store was successfully updated") + expect(Spree::Store.find_by(name: "B2C Store").address).to have_attributes( + name: "B2C Store Inc.", + address1: "1 Store Street", + city: "Storetown", + phone: "555-555-0199", + country: Spree::Country.find_by(iso: "DE") + ) + end end describe "clicking Discard" do diff --git a/admin/spec/requests/solidus_admin/stores_spec.rb b/admin/spec/requests/solidus_admin/stores_spec.rb index 0c8718bd945..f409528d807 100644 --- a/admin/spec/requests/solidus_admin/stores_spec.rb +++ b/admin/spec/requests/solidus_admin/stores_spec.rb @@ -11,4 +11,53 @@ let(:valid_attributes) { {name: "Store", code: "store", url: "store.com", mail_from_address: "store@example.com"} } let(:invalid_attributes) { {name: ""} } end + + describe "address" do + let(:admin_user) { create(:admin_user) } + let(:store) { create(:store, default: false) } + let(:country) { create(:country, states_required: false) } + let(:address_attributes) do + {name: "Store Inc.", address1: "1 Store Street", city: "Storetown", zipcode: "12345", phone: "555-555-0199", vat_id: "DE123456789", country_id: country.id} + end + + before { allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) } + + it "renders the address fields without email, address2 and VAT-ID" do + get solidus_admin.edit_store_path(store) + expect(response.body).to include('name="store[address_attributes][address1]"') + expect(response.body).to include('name="store[address_attributes][phone]"') + expect(response.body).not_to include('name="store[address_attributes][email]"') + expect(response.body).not_to include('name="store[address_attributes][address2]"') + expect(response.body).not_to include('name="store[address_attributes][vat_id]"') + end + + it "does not render the phone field if phone is not required" do + allow(Spree::Config).to receive(:address_requires_phone).and_return(false) + get solidus_admin.edit_store_path(store) + expect(response.body).not_to include('name="store[address_attributes][phone]"') + end + + it "renders the VAT-ID once if reverse charge fields are shown" do + allow(Spree::Backend::Config).to receive(:show_reverse_charge_fields).and_return(true) + get solidus_admin.edit_store_path(store) + expect(response.body.scan('name="store[address_attributes][vat_id]"').size).to eq(1) + end + + it "saves the address" do + patch solidus_admin.store_path(store), params: {store: {address_attributes:}} + expect(store.reload.address).to have_attributes(address_attributes) + end + + it "does not create an address from blank fields" do + patch solidus_admin.store_path(store), params: {store: {address_attributes: {name: "", address1: "", country_id: country.id, reverse_charge_status: "disabled"}}} + expect(response).to have_http_status(:see_other) + expect(store.reload.address).to be_nil + end + + it "creates a store without address from blank fields" do + post solidus_admin.stores_path, params: {store: {name: "New Store", code: "new-store", url: "new-store.com", mail_from_address: "store@example.com", address_attributes: {name: "", address1: "", country_id: country.id}}} + expect(response).to have_http_status(:see_other) + expect(Spree::Store.find_by!(code: "new-store").address).to be_nil + end + end end diff --git a/api/app/views/spree/api/stores/_store.json.jbuilder b/api/app/views/spree/api/stores/_store.json.jbuilder new file mode 100644 index 00000000000..138b5ab5bcd --- /dev/null +++ b/api/app/views/spree/api/stores/_store.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +json.call(store, *store_attributes) +json.address do + if store.address + json.partial!("spree/api/addresses/address", address: store.address) + else + json.nil! + end +end diff --git a/api/app/views/spree/api/stores/index.json.jbuilder b/api/app/views/spree/api/stores/index.json.jbuilder index 35f4557e9ef..c08b8810b9b 100644 --- a/api/app/views/spree/api/stores/index.json.jbuilder +++ b/api/app/views/spree/api/stores/index.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.stores(@stores) { |store| json.call(store, *store_attributes) } +json.stores(@stores) { |store| json.partial!("spree/api/stores/store", store:) } diff --git a/api/app/views/spree/api/stores/show.json.jbuilder b/api/app/views/spree/api/stores/show.json.jbuilder index 84c286ae281..183b546599f 100644 --- a/api/app/views/spree/api/stores/show.json.jbuilder +++ b/api/app/views/spree/api/stores/show.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true -json.call(@store, *store_attributes) +json.partial!("spree/api/stores/store", store: @store) diff --git a/api/openapi/solidus-api.oas.yml b/api/openapi/solidus-api.oas.yml index a59c2662b8f..624a1847d99 100644 --- a/api/openapi/solidus-api.oas.yml +++ b/api/openapi/solidus-api.oas.yml @@ -6474,6 +6474,8 @@ components: type: string url: type: string + address: + $ref: '#/components/schemas/address' taxonomy: type: object properties: @@ -6955,6 +6957,8 @@ components: type: string cart_tax_country_iso: type: string + address_attributes: + $ref: '#/components/schemas/address-input' taxonomy-input: type: object title: Taxonomy input diff --git a/api/spec/requests/spree/api/stores_spec.rb b/api/spec/requests/spree/api/stores_spec.rb index e036e081d9c..2c34dbda8e7 100644 --- a/api/spec/requests/spree/api/stores_spec.rb +++ b/api/spec/requests/spree/api/stores_spec.rb @@ -38,7 +38,8 @@ module Spree::Api "default_currency" => nil, "code" => store.code, "default" => true, - "available_locales" => ["en"] + "available_locales" => ["en"], + "address" => nil }, { "id" => non_default_store.id, @@ -53,7 +54,8 @@ module Spree::Api "default_currency" => nil, "code" => non_default_store.code, "default" => false, - "available_locales" => ["en"] + "available_locales" => ["en"], + "address" => nil } ]) end @@ -73,7 +75,8 @@ module Spree::Api "default_currency" => nil, "code" => store.code, "default" => true, - "available_locales" => ["en"] + "available_locales" => ["en"], + "address" => nil ) end @@ -101,6 +104,50 @@ module Spree::Api expect(store.reload.bcc_email).to eql "bcc@example.net" end + context "address" do + let(:country) { create(:country, states_required: false) } + let(:address_params) do + attributes_for(:address).slice(:name, :address1, :city, :zipcode, :phone).merge(country_id: country.id) + end + + it "includes the address in the store details" do + store.update!(address: create(:address)) + get spree.api_store_path(store) + expect(json_response["address"]).to include( + "id" => store.address.id, + "address1" => store.address.address1, + "city" => store.address.city + ) + expect(json_response["address"]["country"]).to include("iso" => store.address.country.iso) + end + + it "can create a new store with an address" do + store_hash = { + code: "spree123", + name: "Hack0rz", + url: "spree123.example.com", + mail_from_address: "me@example.com", + address_attributes: address_params + } + post spree.api_stores_path, params: {store: store_hash} + expect(response.status).to eq(201) + expect(json_response["address"]).to include("address1" => address_params[:address1]) + end + + it "can update the address of an existing store" do + put spree.api_store_path(store), params: {store: {address_attributes: address_params}} + expect(response.status).to eq(200) + expect(store.reload.address.address1).to eq(address_params[:address1]) + expect(store.address.country).to eq(country) + end + + it "responds with an error for an invalid address" do + put spree.api_store_path(store), params: {store: {address_attributes: address_params.merge(city: "")}} + expect(response.status).to eq(422) + expect(json_response["errors"]).to include("address") + end + end + context "deleting a store" do it "will fail if it's the default Store" do delete spree.api_store_path(store) diff --git a/backend/app/controllers/spree/admin/stores_controller.rb b/backend/app/controllers/spree/admin/stores_controller.rb index 6c78c6f8752..3fc5c9789fc 100644 --- a/backend/app/controllers/spree/admin/stores_controller.rb +++ b/backend/app/controllers/spree/admin/stores_controller.rb @@ -13,6 +13,13 @@ def index private + def permitted_resource_params + super.tap do |store_params| + without_address_parameters = store_params[:address_attributes]&.except(:country_id, :state_id, :reverse_charge_status)&.compact_blank&.empty? + store_params.delete(:address_attributes) if without_address_parameters + end + end + def store_params params.require(:store).permit(permitted_params) end diff --git a/backend/app/views/spree/admin/shared/_address_form.html.erb b/backend/app/views/spree/admin/shared/_address_form.html.erb index b595ef47142..e94fa2cea14 100644 --- a/backend/app/views/spree/admin/shared/_address_form.html.erb +++ b/backend/app/views/spree/admin/shared/_address_form.html.erb @@ -18,10 +18,12 @@ <%= f.text_field :address1, class: 'fullwidth' %>
-
"> - <%= f.label :address2 %> - <%= f.text_field :address2, class: 'fullwidth' %> -
+ <% unless local_assigns.fetch(:excludes, []).include?(:address2) %> +
"> + <%= f.label :address2 %> + <%= f.text_field :address2, class: 'fullwidth' %> +
+ <% end %>
"> <%= f.label :city %> @@ -59,15 +61,19 @@
-
"> - <%= f.label :phone %> - <%= f.phone_field :phone, class: 'fullwidth' %> -
+ <% unless local_assigns.fetch(:excludes, []).include?(:phone) %> +
"> + <%= f.label :phone %> + <%= f.phone_field :phone, class: 'fullwidth' %> +
+ <% end %> -
"> - <%= f.label :email %> - <%= f.email_field :email, class: 'fullwidth' %> -
+ <% unless local_assigns.fetch(:excludes, []).include?(:email) %> +
"> + <%= f.label :email %> + <%= f.email_field :email, class: 'fullwidth' %> +
+ <% end %> <% if Spree::Backend::Config.show_reverse_charge_fields %>
"> diff --git a/backend/app/views/spree/admin/stores/_form.html.erb b/backend/app/views/spree/admin/stores/_form.html.erb index 52cdaf346e8..ae615d900ae 100644 --- a/backend/app/views/spree/admin/stores/_form.html.erb +++ b/backend/app/views/spree/admin/stores/_form.html.erb @@ -41,6 +41,13 @@ <%= f.error_message_on :reverse_charge_status %> <% end %> <% end %> + +
+ <%= Spree::Address.model_name.human %> + <%= f.fields_for :address, f.object.address || Spree::Address.new(country: Spree::Country.find_by(iso: Spree::Config.default_country_iso)) do |address_form| %> + <%= render partial: 'spree/admin/shared/address_form', locals: { f: address_form, type: "store", excludes: Spree::Config.address_requires_phone ? [:address2, :email] : [:address2, :email, :phone] } %> + <% end %> +
<%= f.field_container :url do %> diff --git a/backend/spec/controllers/spree/admin/stores_controller_spec.rb b/backend/spec/controllers/spree/admin/stores_controller_spec.rb new file mode 100644 index 00000000000..05ecd695acd --- /dev/null +++ b/backend/spec/controllers/spree/admin/stores_controller_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Spree::Admin::StoresController, type: :controller do + stub_authorization! + + let(:store) { create(:store) } + let(:country) { create(:country, states_required: false) } + let(:address_attributes) do + {name: "Store Inc.", address1: "1 Store Street", city: "Storetown", zipcode: "12345", phone: "555-555-0199", country_id: country.id} + end + + describe "#edit" do + render_views + + it "renders the address fields without email and address2" do + get :edit, params: {id: store.id} + expect(response.body).to include('name="store[address_attributes][address1]"') + expect(response.body).to include('name="store[address_attributes][phone]"') + expect(response.body).not_to include('name="store[address_attributes][email]"') + expect(response.body).not_to include('name="store[address_attributes][address2]"') + end + + it "does not render the phone field if phone is not required" do + stub_spree_preferences(address_requires_phone: false) + get :edit, params: {id: store.id} + expect(response.body).not_to include('name="store[address_attributes][phone]"') + end + end + + describe "#update" do + it "saves the address" do + put :update, params: {id: store.id, store: {address_attributes:}} + expect(store.reload.address).to have_attributes(address_attributes) + end + + it "does not create an address from blank fields" do + put :update, params: {id: store.id, store: {name: "New Name", address_attributes: {name: "", address1: "", country_id: country.id, reverse_charge_status: "disabled"}}} + expect(store.reload).to have_attributes(name: "New Name", address: nil) + end + end + + describe "#create" do + it "creates a store without address from blank fields" do + post :create, params: {store: {name: "New Store", code: "new-store", url: "new-store.com", mail_from_address: "store@example.com", address_attributes: {name: "", address1: "", country_id: country.id}}} + expect(Spree::Store.find_by!(code: "new-store").address).to be_nil + end + end +end diff --git a/backend/spec/features/admin/configuration/store_spec.rb b/backend/spec/features/admin/configuration/store_spec.rb index 1a566c241d0..7fd5e36400c 100644 --- a/backend/spec/features/admin/configuration/store_spec.rb +++ b/backend/spec/features/admin/configuration/store_spec.rb @@ -11,6 +11,7 @@ end let!(:vat_country) { create(:country, iso: "DE", name: "Germany") } + let!(:store_country) { create(:country, iso: "AT", states_required: false) } before(:each) do visit spree.admin_path @@ -47,6 +48,28 @@ end end + context "editing the store address" do + it "should be able to save the address" do + within("[data-hook='store_address_wrapper']") do + expect(page).not_to have_field("Email") + fill_in "Name", with: "Test Store Inc." + fill_in "Street Address", with: "1 Store Street" + fill_in "City", with: "Storetown" + fill_in "Zip Code", with: "12345" + fill_in "Phone", with: "555-555-0199" + select "Austria", from: "Country" + end + click_button "Update" + + expect(page).to have_content("has been successfully updated") + expect(store.reload.address).to have_attributes( + name: "Test Store Inc.", + address1: "1 Store Street", + country: store_country + ) + end + end + context "update fails" do it "should display the error" do fill_in "Site Name", with: " " diff --git a/core/app/models/spree/store.rb b/core/app/models/spree/store.rb index 75902a43cb7..96866402e87 100644 --- a/core/app/models/spree/store.rb +++ b/core/app/models/spree/store.rb @@ -17,6 +17,8 @@ class Store < Spree::Base has_many :orders, class_name: "Spree::Order" + belongs_to :address, class_name: "Spree::Address", optional: true, validate: true + validates :code, presence: true, uniqueness: {allow_blank: true, case_sensitive: true} validates :name, presence: true validates :url, presence: true @@ -51,6 +53,10 @@ def available_locales=(locales) end end + def address_attributes=(attributes) + self.address = Spree::Address.immutable_merge(address, attributes) + end + def self.default where(default: true).first || new end diff --git a/core/db/migrate/20260924120000_add_address_to_spree_stores.rb b/core/db/migrate/20260924120000_add_address_to_spree_stores.rb new file mode 100644 index 00000000000..db8c02dc290 --- /dev/null +++ b/core/db/migrate/20260924120000_add_address_to_spree_stores.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddAddressToSpreeStores < ActiveRecord::Migration[7.0] + def change + change_table :spree_stores do |t| + t.references :address, type: :integer, foreign_key: {to_table: :spree_addresses} + end + end +end diff --git a/core/lib/spree/permitted_attributes.rb b/core/lib/spree/permitted_attributes.rb index 63e007446de..2e43fd003b2 100644 --- a/core/lib/spree/permitted_attributes.rb +++ b/core/lib/spree/permitted_attributes.rb @@ -125,7 +125,8 @@ module PermittedAttributes @@store_attributes = [:name, :url, :seo_title, :meta_keywords, :meta_description, :default_currency, :mail_from_address, :cart_tax_country_iso, - :bcc_email, :reverse_charge_status] + :bcc_email, :reverse_charge_status, + address_attributes:] @@taxonomy_attributes = [:name] diff --git a/core/lib/spree/testing_support/factories/store_factory.rb b/core/lib/spree/testing_support/factories/store_factory.rb index 008e2c9a823..747054adb7a 100644 --- a/core/lib/spree/testing_support/factories/store_factory.rb +++ b/core/lib/spree/testing_support/factories/store_factory.rb @@ -6,5 +6,9 @@ sequence(:name) { |i| "Spree Test Store #{i}" } sequence(:url) { |i| "www.example#{i}.com" } mail_from_address { "solidus@example.org" } + + trait :with_address do + address + end end end diff --git a/core/spec/models/spree/store_spec.rb b/core/spec/models/spree/store_spec.rb index 24b0d3e404d..c03e25d58eb 100644 --- a/core/spec/models/spree/store_spec.rb +++ b/core/spec/models/spree/store_spec.rb @@ -137,6 +137,52 @@ end end + describe "#address" do + it "is optional" do + expect(build(:store, address: nil)).to be_valid + end + + it "can be assigned" do + store = create(:store, :with_address) + expect(store.reload.address).to be_a(Spree::Address) + end + end + + describe "#address_attributes=" do + let(:address_attributes) { attributes_for(:address).merge(country_id: create(:country, states_required: false).id) } + + it "creates an address" do + store = create(:store, address_attributes:) + expect(store.reload.address).to have_attributes(address_attributes.slice(:name, :address1, :city)) + end + + it "invalidates the store if the address is invalid" do + store = build(:store, address_attributes: address_attributes.merge(city: nil)) + expect(store).not_to be_valid + expect(store.errors).to include(:address) + end + + it "does not modify the existing address on change" do + store = create(:store, :with_address) + original_address = store.address + + store.update!(address_attributes: {city: "New City"}) + + expect(store.reload.address).not_to eq(original_address) + expect(store.address.city).to eq("New City") + expect(original_address.reload.city).not_to eq("New City") + end + + it "keeps the existing address if nothing changed" do + store = create(:store, :with_address) + original_address = store.address + + store.update!(address_attributes: {city: original_address.city}) + + expect(store.reload.address).to eq(original_address) + end + end + describe "#validate_not_default" do context "when deleting a default store" do it "prevents deletion" do