From c84b99e5408aafa5a3af24858c4191e0a67d534c Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 18 Feb 2025 01:05:10 +0530 Subject: [PATCH] Add organization structured data fields to Spree stores for SEO - Added new fields to spree_stores to support organization structured data for SEO. - Fields include legal_name, contact_email, contact_phone, description, vat_id, tax_id, address1, address2, city, zipcode, state_name, country_id, and state_id. - This update enhances SEO by allowing structured data markup for better search engine visibility. Enhance store API schema with additional attributes Updated the API schema for store-related endpoints to include additional store attributes. Update admin view for store new and edit form - Added component for rendering the store edit and new views. - Added address partial as a separate component within the store form. - Added the test cases for new, edit and address form component --- .../stores/address_form/component.html.erb | 50 +++++++ .../stores/address_form/component.js | 56 ++++++++ .../stores/address_form/component.rb | 15 ++ .../stores/edit/component.html.erb | 65 +++++++++ .../solidus_admin/stores/edit/component.rb | 62 ++++++++ .../solidus_admin/stores/edit/component.yml | 6 + .../stores/new/component.html.erb | 65 +++++++++ .../solidus_admin/stores/new/component.rb | 62 ++++++++ .../solidus_admin/stores/new/component.yml | 6 + .../solidus_admin/stores_controller.rb | 20 +++ admin/config/routes.rb | 2 +- .../stores/address_form/component_spec.rb | 35 +++++ .../stores/edit/component_spec.rb | 61 ++++++++ .../stores/new/component_spec.rb | 57 ++++++++ .../requests/solidus_admin/stores_spec.rb | 40 ++++++ api/lib/spree/api_configuration.rb | 7 +- api/openapi/solidus-api.oas.yml | 54 +++++++ api/spec/requests/spree/api/stores_spec.rb | 134 +++++++++++++++++- .../spree/admin/stores/_address_form.html.erb | 65 +++++++++ .../views/spree/admin/stores/_form.html.erb | 26 ++++ core/app/models/spree/store.rb | 4 + core/config/locales/en.yml | 13 ++ ...07_add_store_attributes_to_spree_stores.rb | 19 +++ core/lib/spree/permitted_attributes.rb | 6 +- 24 files changed, 919 insertions(+), 11 deletions(-) create mode 100644 admin/app/components/solidus_admin/stores/address_form/component.html.erb create mode 100644 admin/app/components/solidus_admin/stores/address_form/component.js create mode 100644 admin/app/components/solidus_admin/stores/address_form/component.rb create mode 100644 admin/app/components/solidus_admin/stores/edit/component.html.erb create mode 100644 admin/app/components/solidus_admin/stores/edit/component.rb create mode 100644 admin/app/components/solidus_admin/stores/edit/component.yml create mode 100644 admin/app/components/solidus_admin/stores/new/component.html.erb create mode 100644 admin/app/components/solidus_admin/stores/new/component.rb create mode 100644 admin/app/components/solidus_admin/stores/new/component.yml create mode 100644 admin/spec/components/solidus_admin/stores/address_form/component_spec.rb create mode 100644 admin/spec/components/solidus_admin/stores/edit/component_spec.rb create mode 100644 admin/spec/components/solidus_admin/stores/new/component_spec.rb create mode 100644 admin/spec/requests/solidus_admin/stores_spec.rb create mode 100644 backend/app/views/spree/admin/stores/_address_form.html.erb create mode 100644 core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb diff --git a/admin/app/components/solidus_admin/stores/address_form/component.html.erb b/admin/app/components/solidus_admin/stores/address_form/component.html.erb new file mode 100644 index 00000000000..9eee9d51b51 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/address_form/component.html.erb @@ -0,0 +1,50 @@ +
+
+ <%= render component("ui/forms/field").text_field(@name, :legal_name, object: @store) %> + <%= render component("ui/forms/field").text_field(@name, :address1, object: @store) %> + <%= render component("ui/forms/field").text_field(@name, :address2, object: @store) %> +
+ <%= render component("ui/forms/field").text_field(@name, :city, object: @store) %> + <%= render component("ui/forms/field").text_field(@name, :zipcode, object: @store) %> +
+ + <%= render component("ui/forms/field").select( + @name, + :country_id, + Spree::Country.pluck(:name, :id), + object: @store, + value: @store.country_id, + "data-#{stimulus_id}-target": "country", + "data-action": "change->#{stimulus_id}#loadStates" + ) %> + + <%= content_tag :div, + class: "flex flex-col gap-2 w-full #{'hidden' unless @store.country&.states_required}", + data: { "#{stimulus_id}-target": "stateNameWrapper" } do %> + <%= render component("ui/forms/field").text_field( + @name, :state_name, + object: @store, + value: @store.state_name, + data: { "#{stimulus_id}-target": "stateName" } + ) %> + <% end %> + + > + + <%= content_tag :div, + class: "flex flex-col gap-2 w-full #{'hidden' if @store.country&.states_required}", + data: { "#{stimulus_id}-target": "stateWrapper" } do %> + <%= render component("ui/forms/field").select( + @name, :state_id, + state_options, + object: @store, + value: @store.state_id, + data: { "#{stimulus_id}-target": "state" } + ) %> + <% end %> + + <%= render component("ui/forms/field").text_field(@name, :contact_phone, object: @store) %> +
+
diff --git a/admin/app/components/solidus_admin/stores/address_form/component.js b/admin/app/components/solidus_admin/stores/address_form/component.js new file mode 100644 index 00000000000..1d57e7eb40b --- /dev/null +++ b/admin/app/components/solidus_admin/stores/address_form/component.js @@ -0,0 +1,56 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["country", "state", "stateName", "stateWrapper", "stateNameWrapper"] + + loadStates() { + const countryId = this.countryTarget.value + + fetch(`/admin/countries/${countryId}/states`) + .then((response) => response.json()) + .then((data) => { + this.updateStateOptions(data) + }) + } + + updateStateOptions(states) { + if (states.length === 0) { + this.toggleStateFields(false) + } else { + this.toggleStateFields(true) + this.populateStateSelect(states) + } + } + + toggleStateFields(showSelect) { + const stateWrapper = this.stateWrapperTarget + const stateNameWrapper = this.stateNameWrapperTarget + const stateSelect = this.stateTarget + const stateName = this.stateNameTarget + + if (showSelect) { + // Show state select dropdown. + stateSelect.disabled = false + stateName.value = "" + stateWrapper.classList.remove("hidden") + stateNameWrapper.classList.add("hidden") + } else { + // Show state name text input if no states to choose from. + stateSelect.disabled = true + stateWrapper.classList.add("hidden") + stateNameWrapper.classList.remove("hidden") + } + } + + populateStateSelect(states) { + const stateSelect = this.stateTarget + stateSelect.innerHTML = "" + + states.forEach((state) => { + const option = document.createElement("option") + option.value = state.id + option.innerText = state.name + stateSelect.appendChild(option) + }) + } +} diff --git a/admin/app/components/solidus_admin/stores/address_form/component.rb b/admin/app/components/solidus_admin/stores/address_form/component.rb new file mode 100644 index 00000000000..cd3e96162d1 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/address_form/component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SolidusAdmin::Stores::AddressForm::Component < SolidusAdmin::BaseComponent + def initialize(store:) + @name = "store" + @store = store + end + + def state_options + country = @store.country + return [] unless country && country.states_required + + country.states.pluck(:name, :id) + end +end diff --git a/admin/app/components/solidus_admin/stores/edit/component.html.erb b/admin/app/components/solidus_admin/stores/edit/component.html.erb new file mode 100644 index 00000000000..0fc523ad3c8 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/edit/component.html.erb @@ -0,0 +1,65 @@ +<%= page do %> + <%= page_header do %> + <%= page_header_back(solidus_admin.stores_path) %> + <%= page_header_title(t(".title", store: @store&.name)) %> + <% end %> + + <%= form_for @store, url: solidus_admin.store_path(@store), html: { id: form_id } do |f| %> + <%= page_with_sidebar do %> + <%= page_with_sidebar_main do %> + <%= render component("ui/panel").new(title: t(".store_details")) do %> +
+ <%= render component("ui/forms/field").text_field(f, :name, required: true) %> + <%= render component("ui/forms/field").text_field(f, :code, required: true) %> + <%= render component("ui/forms/field").text_field(f, :seo_title) %> + <%= render component("ui/forms/field").text_field(f, :meta_keywords) %> + <%= render component("ui/forms/field").text_area(f, :meta_description) %> + <%= render component("ui/forms/field").text_field(f, :tax_id) %> + <%= render component("ui/forms/field").text_field(f, :vat_id) %> + <%= render component("ui/forms/field").text_field(f, :url, required: true) %> + <%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %> + <%= render component("ui/forms/field").text_field(f, :bcc_email) %> + <%= render component("ui/forms/field").select( + f, + :default_currency, + currency_options, + include_blank: true + ) %> + <%= render component("ui/forms/field").select( + f, + :cart_tax_country_iso, + cart_tax_country_options, + include_blank: t(".no_cart_tax_country") + ) %> + <%= render component("ui/forms/field").select( + f, + :available_locales, + localization_options, + multiple: true, + class: "select2", + name: "store[available_locales][]" + ) %> +
+ <%= render component("ui/forms/field").text_area(f, :description) %> + <% end %> + + <%= render component("ui/panel").new(title: t(".address")) do %> +
+ <%= render component("stores/address_form").new( + store: @store, + ) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> + + <%= page_footer do %> + <%= page_footer_actions do %> +
+ <%= render component("ui/button").new(tag: :button, text: t(".update"), form: form_id) %> + <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.edit_store_path(@store), scheme: :secondary) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/admin/app/components/solidus_admin/stores/edit/component.rb b/admin/app/components/solidus_admin/stores/edit/component.rb new file mode 100644 index 00000000000..8c170c0a2c9 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/edit/component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class SolidusAdmin::Stores::Edit::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::Layout::PageHelpers + + # Define the necessary attributes for the component + attr_reader :store, :available_countries + + # Initialize the component with required data + def initialize(store:) + @store = store + @available_countries = fetch_available_countries + end + + def form_id + @form_id ||= "#{stimulus_id}--form-#{@store.id}" + end + + def currency_options + Spree::Config.available_currencies.map(&:iso_code) + end + + # Generates options for cart tax countries + def cart_tax_country_options + fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]).map do |country| + [country.name, country.iso] + end + end + + # Generates available locales + def localization_options + Spree.i18n_available_locales.map do |locale| + [ + I18n.t('spree.i18n.this_file_language', locale: locale, default: locale.to_s), + locale + ] + end + end + + # Fetch countries for the address form + def available_country_options + Spree::Country.order(:name).map { |country| [country.name, country.id] } + end + + private + + # Fetch the available countries for the localization section + def fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]) + countries = Spree::Country.available(restrict_to_zone:) + + country_names = Carmen::Country.all.map do |country| + [country.code, country.name] + end.to_h + + country_names.update I18n.t('spree.country_names', default: {}).stringify_keys + + countries.collect do |country| + country.name = country_names.fetch(country.iso, country.name) + country + end.sort_by { |country| country.name.parameterize } + end +end diff --git a/admin/app/components/solidus_admin/stores/edit/component.yml b/admin/app/components/solidus_admin/stores/edit/component.yml new file mode 100644 index 00000000000..ae5ffcf75d1 --- /dev/null +++ b/admin/app/components/solidus_admin/stores/edit/component.yml @@ -0,0 +1,6 @@ +en: + title: "%{store}" + store_details: Store Details + address: Address + update: Update + cancel: Cancel diff --git a/admin/app/components/solidus_admin/stores/new/component.html.erb b/admin/app/components/solidus_admin/stores/new/component.html.erb new file mode 100644 index 00000000000..a1cba8b493f --- /dev/null +++ b/admin/app/components/solidus_admin/stores/new/component.html.erb @@ -0,0 +1,65 @@ +<%= page do %> + <%= page_header do %> + <%= page_header_back(solidus_admin.stores_path) %> + <%= page_header_title(t(".title")) %> + <% end %> + + <%= form_for @store, url: solidus_admin.stores_path, html: { id: form_id } do |f| %> + <%= page_with_sidebar do %> + <%= page_with_sidebar_main do %> + <%= render component("ui/panel").new(title: t(".store_details")) do %> +
+ <%= render component("ui/forms/field").text_field(f, :name, required: true) %> + <%= render component("ui/forms/field").text_field(f, :code, required: true) %> + <%= render component("ui/forms/field").text_field(f, :seo_title) %> + <%= render component("ui/forms/field").text_field(f, :meta_keywords) %> + <%= render component("ui/forms/field").text_area(f, :meta_description) %> + <%= render component("ui/forms/field").text_field(f, :tax_id) %> + <%= render component("ui/forms/field").text_field(f, :vat_id) %> + <%= render component("ui/forms/field").text_field(f, :url, required: true) %> + <%= render component("ui/forms/field").text_field(f, :mail_from_address, required: true) %> + <%= render component("ui/forms/field").text_field(f, :bcc_email) %> + <%= render component("ui/forms/field").select( + f, + :default_currency, + currency_options, + include_blank: true + ) %> + <%= render component("ui/forms/field").select( + f, + :cart_tax_country_iso, + cart_tax_country_options, + include_blank: t(".no_cart_tax_country") + ) %> + <%= render component("ui/forms/field").select( + f, + :available_locales, + localization_options, + multiple: true, + class: "select2", + name: "store[available_locales][]" + ) %> +
+ <%= render component("ui/forms/field").text_area(f, :description) %> + <% end %> + + <%= render component("ui/panel").new(title: t(".address")) do %> +
+ <%= render component("stores/address_form").new( + store: @store, + ) %> +
+ <% end %> + <% end %> + <% end %> + <% end %> + + <%= page_footer do %> + <%= page_footer_actions do %> +
+ <%= render component("ui/button").new(tag: :button, text: t(".save"), form: form_id) %> + <%= render component("ui/button").new(tag: :a, text: t(".cancel"), href: solidus_admin.new_store_path, scheme: :secondary) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/admin/app/components/solidus_admin/stores/new/component.rb b/admin/app/components/solidus_admin/stores/new/component.rb new file mode 100644 index 00000000000..a1c7d288f8f --- /dev/null +++ b/admin/app/components/solidus_admin/stores/new/component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class SolidusAdmin::Stores::New::Component < SolidusAdmin::BaseComponent + include SolidusAdmin::Layout::PageHelpers + + # Define the necessary attributes for the component + attr_reader :store, :available_countries + + # Initialize the component with required data + def initialize(store:) + @store = store + @available_countries = fetch_available_countries + end + + def form_id + @form_id ||= "#{stimulus_id}--form-#{@store.id}" + end + + def currency_options + Spree::Config.available_currencies.map(&:iso_code) + end + + # Generates options for cart tax countries + def cart_tax_country_options + fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]).map do |country| + [country.name, country.iso] + end + end + + # Generates available locales + def localization_options + Spree.i18n_available_locales.map do |locale| + [ + I18n.t('spree.i18n.this_file_language', locale: locale, default: locale.to_s), + locale + ] + end + end + + # Fetch countries for the address form + def available_country_options + Spree::Country.order(:name).map { |country| [country.name, country.id] } + end + + private + + # Fetch the available countries for the localization section + def fetch_available_countries(restrict_to_zone: Spree::Config[:checkout_zone]) + countries = Spree::Country.available(restrict_to_zone:) + + country_names = Carmen::Country.all.map do |country| + [country.code, country.name] + end.to_h + + country_names.update I18n.t('spree.country_names', default: {}).stringify_keys + + countries.collect do |country| + country.name = country_names.fetch(country.iso, country.name) + country + end.sort_by { |country| country.name.parameterize } + end +end diff --git a/admin/app/components/solidus_admin/stores/new/component.yml b/admin/app/components/solidus_admin/stores/new/component.yml new file mode 100644 index 00000000000..f189c79fcda --- /dev/null +++ b/admin/app/components/solidus_admin/stores/new/component.yml @@ -0,0 +1,6 @@ +en: + title: "New Store" + save: Save + store_details: Store Details + address: Address + cancel: Cancel diff --git a/admin/app/controllers/solidus_admin/stores_controller.rb b/admin/app/controllers/solidus_admin/stores_controller.rb index c653163d5c9..162e434392d 100644 --- a/admin/app/controllers/solidus_admin/stores_controller.rb +++ b/admin/app/controllers/solidus_admin/stores_controller.rb @@ -17,6 +17,26 @@ def index end end + def new + @store ||= Spree::Store.new + + respond_to do |format| + format.html { + render component("stores/new").new( + store: @store + ) + } + end + end + + def edit + @store = Spree::Store.find_by(id: params[:id]) + + respond_to do |format| + format.html { render component('stores/edit').new(store: @store) } + end + end + def destroy @stores = Spree::Store.where(id: params[:id]) diff --git a/admin/config/routes.rb b/admin/config/routes.rb index 4f70c26e766..88607c30600 100644 --- a/admin/config/routes.rb +++ b/admin/config/routes.rb @@ -77,7 +77,7 @@ admin_resources :shipping_methods, only: [:index, :destroy] admin_resources :shipping_categories, except: [:show] admin_resources :stock_locations, only: [:index, :destroy] - admin_resources :stores, only: [:index, :destroy] + admin_resources :stores, only: [:index, :edit, :show, :destroy, :new] admin_resources :zones, only: [:index, :destroy] admin_resources :refund_reasons, except: [:show] admin_resources :reimbursement_types, only: [:index] diff --git a/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb b/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb new file mode 100644 index 00000000000..4382ecd2157 --- /dev/null +++ b/admin/spec/components/solidus_admin/stores/address_form/component_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::Stores::AddressForm::Component, type: :component do + let(:country) { create(:country, states_required: true) } + let(:state) { create(:state, country: country) } + let(:store) { create(:store, country: country, state: state) } + + subject(:component) { described_class.new(store: store) } + + describe "#state_options" do + context "when the country has states and requires states" do + it "returns a list of state names and IDs" do + expect(component.state_options).to include([state.name, state.id]) + end + end + + context "when the country does not require states" do + let(:country) { create(:country, states_required: false) } + + it "returns an empty array" do + expect(component.state_options).to eq([]) + end + end + + context "when there is no country assigned to the store" do + let(:store) { create(:store, country: nil) } + + it "returns an empty array" do + expect(component.state_options).to eq([]) + end + end + end +end diff --git a/admin/spec/components/solidus_admin/stores/edit/component_spec.rb b/admin/spec/components/solidus_admin/stores/edit/component_spec.rb new file mode 100644 index 00000000000..780579d7437 --- /dev/null +++ b/admin/spec/components/solidus_admin/stores/edit/component_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::Stores::Edit::Component, type: :component do + let(:store) { build(:store, id: 1, name: "Test Store") } + let(:component) { described_class.new(store: store) } + + describe "#render" do + it "renders the edit store form with existing data" do + store = Spree::Store.create!(name: "Test Store", url: "test-store.com", code: 'test-store', mail_from_address: 'test@mail.co') + render_inline described_class.new(store: store) + + expect(rendered_content).to have_selector("form") + expect(rendered_content).to have_field("store[name]", with: "Test Store") + expect(rendered_content).to have_field("store[url]", with: "test-store.com") + expect(rendered_content).to have_field("store[code]", with: "test-store") + expect(rendered_content).to have_field("store[mail_from_address]", with: "test@mail.co") + end + end + + describe "#form_id" do + it "returns a unique form id based on the store" do + expect(component.form_id).to match(/stores--edit--form-1/) + end + end + + describe "#currency_options" do + it "returns the available currencies" do + allow(Spree::Config).to receive(:available_currencies).and_return([Money::Currency.new("USD"), Money::Currency.new("EUR")]) + + expect(component.currency_options).to contain_exactly("USD", "EUR") + end + end + + describe "#cart_tax_country_options" do + it "returns available countries for cart tax selection" do + country = create(:country, name: "United States of America", iso: "US") + allow(Spree::Country).to receive(:available).and_return([country]) + + expect(component.cart_tax_country_options).to include(["United States of America", "US"]) + end + end + + describe "#localization_options" do + it "returns available locales with translated names" do + allow(Spree).to receive(:i18n_available_locales).and_return([:en, :fr]) + expect(component.localization_options).to include(["English (US)", :en]) + expect(component.localization_options).to include(["English (US)", :fr]) + end + end + + describe "#available_country_options" do + it "returns a list of available countries" do + country = create(:country, name: "United States", id: 1) + allow(Spree::Country).to receive(:order).and_return([country]) + + expect(component.available_country_options).to include(["United States", 1]) + end + end +end diff --git a/admin/spec/components/solidus_admin/stores/new/component_spec.rb b/admin/spec/components/solidus_admin/stores/new/component_spec.rb new file mode 100644 index 00000000000..300e584a225 --- /dev/null +++ b/admin/spec/components/solidus_admin/stores/new/component_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusAdmin::Stores::New::Component, type: :component do + let(:store) { Spree::Store.new } + let(:component) { described_class.new(store: store) } + + describe "#render" do + it "renders the new store form" do + store = Spree::Store.new + render_inline described_class.new(store: store) + + expect(rendered_content).to have_selector("form") + expect(rendered_content).to have_field("store[name]") + expect(rendered_content).to have_field("store[url]") + end + end + + describe "#form_id" do + it "generates a unique form ID for the store" do + expect(component.form_id).to match(/--form-/) + end + end + + describe "#currency_options" do + it "returns a list of available currency ISO codes" do + allow(Spree::Config).to receive(:available_currencies).and_return([Money::Currency.new("USD"), Money::Currency.new("EUR")]) + expect(component.currency_options).to contain_exactly("USD", "EUR") + end + end + + describe "#cart_tax_country_options" do + it "returns an array of available tax country names and ISO codes" do + country = create(:country, name: "United States", iso: "US") + allow(component).to receive(:fetch_available_countries).and_return([country]) + expect(component.cart_tax_country_options).to include(["United States", "US"]) + end + end + + describe "#localization_options" do + it "returns available locales with translated names" do + allow(Spree).to receive(:i18n_available_locales).and_return([:en, :fr]) + expect(component.localization_options).to include(["English (US)", :en]) + expect(component.localization_options).to include(["English (US)", :fr]) + end + end + + describe "#available_country_options" do + it "returns a list of available countries sorted by name" do + country1 = create(:country, name: "Germany", id: 1) + country2 = create(:country, name: "France", id: 2) + allow(Spree::Country).to receive(:order).and_return([country1, country2]) + expect(component.available_country_options).to eq([["Germany", 1], ["France", 2]]) + end + end +end diff --git a/admin/spec/requests/solidus_admin/stores_spec.rb b/admin/spec/requests/solidus_admin/stores_spec.rb new file mode 100644 index 00000000000..6ed6fb71ab7 --- /dev/null +++ b/admin/spec/requests/solidus_admin/stores_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "SolidusAdmin::StoresController", type: :request do + let(:admin_user) { create(:admin_user) } + + before do + allow_any_instance_of(SolidusAdmin::BaseController).to receive(:spree_current_user).and_return(admin_user) + allow(admin_user).to receive(:has_spree_role?).with('admin').and_return(true) + end + + let(:resource_class) { Spree::Store } + let(:valid_attributes) { { name: "New Store", code: "new-store" } } + let(:invalid_attributes) { { name: "", code: "", domain: "" } } + + describe "GET /new" do + it "renders the new template with a 200 OK status" do + get solidus_admin.new_store_path + expect(response).to have_http_status(:ok) + end + end + + describe "GET /edit" do + let(:store) { create(:store) } + + it "renders the edit template with a 200 OK status" do + get solidus_admin.edit_store_path(store) + expect(response).to have_http_status(:ok) + end + end + + describe "Strong Parameters" do + it "permits the expected parameters" do + params = ActionController::Parameters.new(store: { store_id: 1, name: "Test Store", code: "test-store" }) + permitted_params = params.require(:store).permit(:store_id, :name, :code) + expect(permitted_params.keys).to contain_exactly("store_id", "name", "code") + end + end +end diff --git a/api/lib/spree/api_configuration.rb b/api/lib/spree/api_configuration.rb index 77b047f3258..1916cfd04ee 100644 --- a/api/lib/spree/api_configuration.rb +++ b/api/lib/spree/api_configuration.rb @@ -147,9 +147,10 @@ def promotion_attributes=(value) deprecate "promotion_attributes=" => promotion_attributes_deprecation_message, deprecator: Spree.deprecator preference :store_attributes, :array, default: [ - :id, :name, :url, :meta_description, :meta_keywords, :seo_title, - :mail_from_address, :default_currency, :code, :default, :available_locales, - :bcc_email + :id, :name, :legal_name, :url, :meta_description, :meta_keywords, :seo_title, + :mail_from_address, :default_currency, :code, :default, + :bcc_email, :contact_phone, :contact_email, :tax_id, :vat_id, :description, + :address1, :address2, :city, :zipcode, :country_id, :state_id, :state_name, :available_locales ] preference :store_credit_history_attributes, :array, default: [ diff --git a/api/openapi/solidus-api.oas.yml b/api/openapi/solidus-api.oas.yml index a59c2662b8f..f324ebdf717 100644 --- a/api/openapi/solidus-api.oas.yml +++ b/api/openapi/solidus-api.oas.yml @@ -6474,6 +6474,33 @@ components: type: string url: type: string + legal_name: + type: string + contact_email: + type: string + nullable: true + contact_phone: + type: string + description: + type: string + vat_id: + type: string + tax_id: + type: string + address1: + type: string + address2: + type: string + city: + type: string + zipcode: + type: string + state_name: + type: string + country_id: + type: integer + state_id: + type: integer taxonomy: type: object properties: @@ -6955,6 +6982,33 @@ components: type: string cart_tax_country_iso: type: string + legal_name: + type: string + contact_email: + type: string + nullable: true + contact_phone: + type: string + description: + type: string + vat_id: + type: string + tax_id: + type: string + address1: + type: string + address2: + type: string + city: + type: string + zipcode: + type: string + state_name: + type: string + country_id: + type: integer + state_id: + type: integer 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 55b4f102416..8553ddf3d6a 100644 --- a/api/spec/requests/spree/api/stores_spec.rb +++ b/api/spec/requests/spree/api/stores_spec.rb @@ -4,6 +4,11 @@ module Spree::Api describe 'Stores', type: :request do + let(:country) { create :country, states_required: true } + let(:country_without_states) { create :country, states_required: false } + let(:state) { create :state, name: 'maryland', abbr: 'md', country: } + let!(:base_attributes) { Spree::Api::Config.store_attributes } + let!(:store) do create(:store, name: "My Spree Store", url: "spreestore.example.com") end @@ -22,6 +27,58 @@ module Spree::Api default: false) end + describe "store state validation" do + context "when store country has states_required" do + it "is invalid without a state" do + store = Spree::Store.new(name: "Test Store", country: country, state: nil, url: "spreestore.example.com", + mail_from_address: "spreestore@example.com", code: "test-store",) + expect(store).not_to be_valid + expect(store.errors[:state]).to include("can't be blank") + end + + it "is valid with a state" do + store = Spree::Store.new(name: "Test Store", country: country, state: state, url: "spreestore.example.com", + mail_from_address: "spreestore@example.com", code: "test-store",) + expect(store).to be_valid + end + end + + context "when store country has no states" do + it "is valid without a state" do + store = Spree::Store.new(name: "Test Store", country: country_without_states, state: nil, url: "spreestore.example.com", + mail_from_address: "spreestore@example.com", code: "test-store",) + expect(store).to be_valid + end + end + + it "is valid without an address and without country/state" do + expect(store).to be_valid + end + + it "is valid with only correct country and state" do + store = Spree::Store.create!( + name: "Test Store", + url: "spreestore.example.com", + mail_from_address: "spreestore.example.com", + code: "test-store", + address1: "123 Main St", + city: "New York", + zipcode: "10001", + state: state, + country: country, + ) + expect(store).to be_valid + end + end + + describe "#index" do + it "ensures the API store attributes match the expected attributes" do + get spree.api_stores_path + first_store = json_response["stores"].first + expect(first_store.keys).to include(*base_attributes.map(&:to_s)) + end + end + it "can list the available stores" do get spree.api_stores_path expect(json_response["stores"]).to match_array([ @@ -37,7 +94,20 @@ module Spree::Api "default_currency" => nil, "code" => store.code, "default" => true, - "available_locales" => ["en"] + "available_locales" => ["en"], + "legal_name" => nil, + "contact_email" => nil, + "contact_phone" => nil, + "description" => nil, + "tax_id" => nil, + "vat_id" => nil, + "address1" => nil, + "address2" => nil, + "city" => nil, + "zipcode" => nil, + "country_id" => nil, + "state_id" => nil, + "state_name" => nil }, { "id" => non_default_store.id, @@ -51,7 +121,20 @@ module Spree::Api "default_currency" => nil, "code" => non_default_store.code, "default" => false, - "available_locales" => ["en"] + "available_locales" => ["en"], + "legal_name" => nil, + "contact_email" => nil, + "contact_phone" => nil, + "description" => nil, + "tax_id" => nil, + "vat_id" => nil, + "address1" => nil, + "address2" => nil, + "city" => nil, + "zipcode" => nil, + "country_id" => nil, + "state_id" => nil, + "state_name" => nil } ]) end @@ -70,7 +153,20 @@ module Spree::Api "default_currency" => nil, "code" => store.code, "default" => true, - "available_locales" => ["en"] + "available_locales" => ["en"], + "legal_name" => nil, + "contact_email" => nil, + "contact_phone" => nil, + "description" => nil, + "tax_id" => nil, + "vat_id" => nil, + "address1" => nil, + "address2" => nil, + "city" => nil, + "zipcode" => nil, + "country_id" => nil, + "state_id" => nil, + "state_name" => nil ) end @@ -79,7 +175,14 @@ module Spree::Api code: "spree123", name: "Hack0rz", url: "spree123.example.com", - mail_from_address: "me@example.com" + mail_from_address: "me@example.com", + legal_name: 'ABC Corp', + address1: "123 Main St", + city: 'San Francisco', + country_id: country.id, + state_id: state.id, + phone: "123-456-7890", + zipcode: "12345" } post spree.api_stores_path, params: { store: store_hash } expect(response.status).to eq(201) @@ -89,13 +192,34 @@ module Spree::Api store_hash = { url: "spree123.example.com", mail_from_address: "me@example.com", - bcc_email: "bcc@example.net" + bcc_email: "bcc@example.net", + legal_name: 'XYZ Corp', + description: "Leading provider of high-quality tech accessories, offering the latest gadgets, peripherals, and electronics to enhance your digital lifestyle.", + tax_id: "TX-987654321", + vat_id: "VAT-123456789", + address1: "123 Innovation Drive", + address2: "Suite 456", + city: "New York", + country_id: country.id, + state_id: state.id, + contact_phone: "123-456-7888", + zipcode: "10001" } put spree.api_store_path(store), params: { store: store_hash } expect(response.status).to eq(200) expect(store.reload.url).to eql "spree123.example.com" expect(store.reload.mail_from_address).to eql "me@example.com" expect(store.reload.bcc_email).to eql "bcc@example.net" + expect(store.reload.legal_name).to eql "XYZ Corp" + expect(store.reload.tax_id).to eql "TX-987654321" + expect(store.reload.vat_id).to eql "VAT-123456789" + expect(store.reload.address1).to eql "123 Innovation Drive" + expect(store.reload.address2).to eql "Suite 456" + expect(store.reload.city).to eql "New York" + expect(store.reload.country_id).to eql country.id + expect(store.reload.state_id).to eql state.id + expect(store.reload.contact_phone).to eql "123-456-7888" + expect(store.reload.zipcode).to eql "10001" end context "deleting a store" do diff --git a/backend/app/views/spree/admin/stores/_address_form.html.erb b/backend/app/views/spree/admin/stores/_address_form.html.erb new file mode 100644 index 00000000000..0450b311644 --- /dev/null +++ b/backend/app/views/spree/admin/stores/_address_form.html.erb @@ -0,0 +1,65 @@ + +<% s_or_b = type.chars.first %> +
+ +
+
+
"> + <%= f.label :legal_name %> + <%= f.text_field :legal_name, class: 'fullwidth' %> +
+ +
"> + <%= f.label :address1 %> + <%= f.text_field :address1, class: 'fullwidth' %> +
+ +
"> + <%= f.label :address2 %> + <%= f.text_field :address2, class: 'fullwidth' %> +
+ +
"> + <%= f.label :contact_phone %> + <%= f.phone_field :contact_phone, class: 'fullwidth' %> +
+
+ +
+
"> + <%= f.label :city %> + <%= f.text_field :city, class: 'fullwidth' %> +
+ +
"> + <%= f.label :zipcode %> + <%= f.text_field :zipcode, class: 'fullwidth' %> +
+ +
"> + <%= f.label :country_id, Spree::Country.model_name.human %> + + <%= f.collection_select :country_id, available_countries, :id, :name, { include_blank: true }, {class: 'custom-select fullwidth js-country_id'} %> + +
+ +
"> + <%= f.label :state_id, Spree::State.model_name.human %> + + <%= f.hidden_field :state_name, value: nil %> + <% states = f.object.country.try(:states).nil? ? [] : f.object.country.states %> + <%= f.text_field :state_name, + style: "display: #{states.empty? ? 'block' : 'none' };", + disabled: !states.empty?, class: 'fullwidth state_name js-state_name' %> + <%= f.hidden_field :state_id, value: nil %> + <%= f.collection_select :state_id, + states.sort, + :id, :name, + { include_blank: true }, + { class: 'custom-select fullwidth js-state_id', + style: "display: #{states.empty? ? 'none' : 'block' };", + disabled: states.empty? } %> + +
+
+
diff --git a/backend/app/views/spree/admin/stores/_form.html.erb b/backend/app/views/spree/admin/stores/_form.html.erb index 59dc4a4e899..6d6563af063 100644 --- a/backend/app/views/spree/admin/stores/_form.html.erb +++ b/backend/app/views/spree/admin/stores/_form.html.erb @@ -30,7 +30,21 @@ <%= f.text_area :meta_description, class: 'fullwidth' %> <%= f.error_message_on :meta_description %> <% end %> + + <%= f.field_container :tax_id do %> + <%= f.label :tax_id %> + <%= f.text_field :tax_id, class: 'fullwidth' %> + <%= f.error_message_on :tax_id %> + <% end %> + + <%= f.field_container :vat_id do %> + <%= f.label :vat_id %> + <%= f.text_field :vat_id, class: 'fullwidth' %> + <%= f.error_message_on :vat_id %> + <% end %> + +
<%= f.field_container :url do %> <%= f.label :url, class: 'required' %> @@ -81,5 +95,17 @@ { class: 'select2 fullwidth', multiple: true } %> <%= f.error_message_on :default_currency %> <% end %> + + <%= f.field_container :description do %> + <%= f.label :description %> + <%= f.text_area :description, class: 'fullwidth' %> + <%= f.error_message_on :description %> + <% end %> +
+ +
+
+ <%= render partial: 'address_form', locals: { f: f, type: 'store' } %> +
diff --git a/core/app/models/spree/store.rb b/core/app/models/spree/store.rb index 9350fb0a2b8..462c55682cd 100644 --- a/core/app/models/spree/store.rb +++ b/core/app/models/spree/store.rb @@ -15,12 +15,16 @@ class Store < Spree::Base has_many :store_shipping_methods, inverse_of: :store has_many :shipping_methods, through: :store_shipping_methods + belongs_to :state, class_name: 'Spree::State', optional: true + belongs_to :country, class_name: 'Spree::Country', optional: true + has_many :orders, class_name: "Spree::Order" validates :code, presence: true, uniqueness: { allow_blank: true, case_sensitive: true } validates :name, presence: true validates :url, presence: true validates :mail_from_address, presence: true + validates :state, presence: true, if: -> { country&.states_required } self.allowed_ransackable_attributes = %w[name url code] diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 0dd3145a750..f75958f39f6 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -354,18 +354,31 @@ en: quantity: Quantity variant: Variant spree/store: + address: Address + address1: Street Address + address2: Street Address (cont'd) available_locales: Locales Available in the Storefront bcc_email: BCC Email cart_tax_country_iso: Tax Country for Empty Carts + city: City code: Slug + contact_email: Contact Email + contact_phone: Contact Phone + country_id: Country default: Default default_currency: Default Currency + description: Store Description + legal_name: Legal Name mail_from_address: Mail From Address meta_description: Meta Description meta_keywords: Meta Keywords name: Site Name + postal_code: Postal Code seo_title: Seo Title + state_id: State + tax_id: Tax ID url: Site URL + vat_id: VAT ID spree/store_credit: amount: Amount amount_authorized: Amount Authorized diff --git a/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb b/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb new file mode 100644 index 00000000000..f1a854f2b7b --- /dev/null +++ b/core/db/migrate/20250202173007_add_store_attributes_to_spree_stores.rb @@ -0,0 +1,19 @@ +class AddStoreAttributesToSpreeStores < ActiveRecord::Migration[7.2] + def change + add_column :spree_stores, :legal_name, :string + add_column :spree_stores, :contact_email, :string + add_column :spree_stores, :description, :text + add_column :spree_stores, :vat_id, :string + add_column :spree_stores, :tax_id, :string + add_column :spree_stores, :address1, :string + add_column :spree_stores, :address2, :string + add_column :spree_stores, :city, :string + add_column :spree_stores, :zipcode, :string + add_column :spree_stores, :state_name, :string + add_column :spree_stores, :contact_phone, :string + add_column :spree_stores, :country_id, :integer + add_column :spree_stores, :state_id, :integer + add_index :spree_stores, :country_id + add_index :spree_stores, :state_id + end +end diff --git a/core/lib/spree/permitted_attributes.rb b/core/lib/spree/permitted_attributes.rb index cd59e762e21..eecb914fd57 100644 --- a/core/lib/spree/permitted_attributes.rb +++ b/core/lib/spree/permitted_attributes.rb @@ -121,10 +121,12 @@ module PermittedAttributes :quantity, :stock_item, :stock_item_id, :originator, :action ] - @@store_attributes = [:name, :url, :seo_title, :meta_keywords, + @@store_attributes = [:name, :legal_name, :url, :seo_title, :meta_keywords, :meta_description, :default_currency, :mail_from_address, :cart_tax_country_iso, - :bcc_email] + :bcc_email, :contact_email, :contact_phone, :code, + :tax_id, :vat_id, :description, :address1, :address2, + :city, :zipcode, :country_id, :state_id, :state_name] @@taxonomy_attributes = [:name]