diff --git a/app/controllers/admin/vouchers_controller.rb b/app/controllers/admin/vouchers_controller.rb new file mode 100644 index 0000000000..70bf0c75a1 --- /dev/null +++ b/app/controllers/admin/vouchers_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Admin + class VouchersController < ResourceController + before_action :load_enterprise + + def new + @voucher = Voucher.new + end + + def create + voucher_params = permitted_resource_params.merge(enterprise: @enterprise) + @voucher = Voucher.create(voucher_params) + + if @voucher.save + redirect_to( + "#{edit_admin_enterprise_path(@enterprise)}#vouchers_panel", + flash: { success: flash_message_for(@voucher, :successfully_created) } + ) + else + flash[:error] = @voucher.errors.full_messages.to_sentence + render :new + end + end + + private + + def load_enterprise + @enterprise = Enterprise.find_by permalink: params[:enterprise_id] + end + + def permitted_resource_params + params.require(:voucher).permit(:code) + end + end +end diff --git a/app/helpers/admin/enterprises_helper.rb b/app/helpers/admin/enterprises_helper.rb index e95f4589ed..25dae316f8 100644 --- a/app/helpers/admin/enterprises_helper.rb +++ b/app/helpers/admin/enterprises_helper.rb @@ -14,6 +14,7 @@ module Admin producers.size == 1 ? producers.first.id : nil end + # rubocop:disable Metrics/MethodLength def enterprise_side_menu_items(enterprise) is_shop = enterprise.sells != "none" show_properties = !!enterprise.is_primary_producer @@ -34,6 +35,7 @@ module Admin { name: 'shipping_methods', icon_class: "icon-truck", show: show_shipping_methods }, { name: 'payment_methods', icon_class: "icon-money", show: show_payment_methods }, { name: 'enterprise_fees', icon_class: "icon-tasks", show: show_enterprise_fees }, + { name: 'vouchers', icon_class: "icon-ticket", show: true }, { name: 'enterprise_permissions', icon_class: "icon-plug", show: true, href: admin_enterprise_relationships_path }, { name: 'inventory_settings', icon_class: "icon-list-ol", show: is_shop }, @@ -42,5 +44,6 @@ module Admin { name: 'users', icon_class: "icon-user", show: true } ] end + # rubocop:enable Metrics/MethodLength end end diff --git a/app/models/enterprise.rb b/app/models/enterprise.rb index 3d7d646c6c..a5d4fe6ff6 100644 --- a/app/models/enterprise.rb +++ b/app/models/enterprise.rb @@ -65,6 +65,7 @@ class Enterprise < ApplicationRecord has_many :inventory_items has_many :tag_rules has_one :stripe_account, dependent: :destroy + has_many :vouchers delegate :latitude, :longitude, :city, :state_name, to: :address diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index cfa3fa0f68..dee02528e4 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -179,6 +179,8 @@ module Spree can [:admin, :create], :manager_invitation can [:admin, :index], :oidc_setting + + can [:admin, :create], Voucher end def add_product_management_abilities(user) diff --git a/app/models/voucher.rb b/app/models/voucher.rb new file mode 100644 index 0000000000..28c74ca7de --- /dev/null +++ b/app/models/voucher.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: false + +class Voucher < ApplicationRecord + belongs_to :enterprise + + validates :code, presence: true, uniqueness: { scope: :enterprise_id } + + def value + 10 + end + + def display_value + Spree::Money.new(value) + end +end diff --git a/app/views/admin/enterprises/_form.html.haml b/app/views/admin/enterprises/_form.html.haml index dcaae1a149..87aeaa2d29 100644 --- a/app/views/admin/enterprises/_form.html.haml +++ b/app/views/admin/enterprises/_form.html.haml @@ -9,6 +9,12 @@ %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} %legend= t(".#{ item[:name] }.legend") + - when 'vouchers' + - if feature?(:vouchers, spree_current_user) + %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} + %legend= t(".#{ item[:form_name] || item[:name] }.legend") + = render "admin/enterprises/form/#{ item[:form_name] || item[:name] }", f: f + - else %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} %legend= t(".#{ item[:form_name] || item[:name] }.legend") diff --git a/app/views/admin/enterprises/form/_vouchers.html.haml b/app/views/admin/enterprises/form/_vouchers.html.haml new file mode 100644 index 0000000000..8df72f951c --- /dev/null +++ b/app/views/admin/enterprises/form/_vouchers.html.haml @@ -0,0 +1,33 @@ +.text-right + %a.button{ href: "#{new_admin_enterprise_voucher_path(@enterprise)}"} + = t('.add_new') +%br + +- if @enterprise.vouchers.present? + %table + %thead + %tr + %th= t('.voucher_code') + %th= t('.rate') + /%th= t('.label') + /%th= t('.purpose') + /%th= t('.expiry') + /%th= t('.use_limit') + /%th= t('.customers') + /%th= t('.net_value') + %tbody + - @enterprise.vouchers.each do |voucher| + %tr + %td= voucher.code + %td= voucher.display_value + /%td + /%td + /%td + /%td + /%td + /%td + +- else + %p.text-center + = t('.no_voucher_yet') + diff --git a/app/views/admin/shared/_side_menu.html.haml b/app/views/admin/shared/_side_menu.html.haml index 18c9e4a04b..f9f38610bd 100644 --- a/app/views/admin/shared/_side_menu.html.haml +++ b/app/views/admin/shared/_side_menu.html.haml @@ -1,7 +1,7 @@ .side_menu#side_menu - if @enterprise - enterprise_side_menu_items(@enterprise).each do |item| - - next unless item[:show] + - next if !item[:show] || (item[:name] == 'vouchers' && !feature?(:vouchers, spree_current_user)) %a.menu_item{ href: item[:href] || "##{item[:name]}_panel", id: item[:name], data: { action: "tabs-and-panels#changeActivePanel tabs-and-panels#changeActiveTab", "tabs-and-panels-target": "tab" }, class: item[:selected] } %i{ class: item[:icon_class] } %span= t(".enterprise.#{item[:name] }") diff --git a/app/views/admin/vouchers/new.html.haml b/app/views/admin/vouchers/new.html.haml new file mode 100644 index 0000000000..806d49856b --- /dev/null +++ b/app/views/admin/vouchers/new.html.haml @@ -0,0 +1,23 @@ += form_with model: @voucher, url: admin_enterprise_vouchers_path(@enterprise), html: { name: "voucher_form" } do |f| + .row + .sixteen.columns.alpha + .four.columns.alpha.text-right + %a.button{ href: "#{edit_admin_enterprise_path(@enterprise)}#!#vouchers_panel"} + = t('.back') + .twelve.columns.omega + .row + .eight.columns.text-center + %legend= t(".legend") + .four.columns.text-right + = f.submit t('.save'), class: 'red' + .row + .alpha.four.columns + = f.label :code, t('.voucher_code') + .omega.eight.columns + = f.text_area :code, rows: 6, class: 'fullwidth' + .row + .alpha.four.columns + = f.label :amount, t('.voucher_amount') + .omega.eight.columns + = Spree::Money.currency_symbol + = f.text_field :amount, value: @voucher.value, disabled: true diff --git a/app/views/spree/admin/shared/_tabs.html.haml b/app/views/spree/admin/shared/_tabs.html.haml index 6613a24643..91efb77da3 100644 --- a/app/views/spree/admin/shared/_tabs.html.haml +++ b/app/views/spree/admin/shared/_tabs.html.haml @@ -4,7 +4,7 @@ = tab :orders, :subscriptions, :customer_details, :adjustments, :payments, :return_authorizations, url: admin_orders_path('q[s]' => 'completed_at desc'), icon: 'icon-shopping-cart' = tab :reports, url: main_app.admin_reports_path, icon: 'icon-file' = tab :general_settings, :mail_methods, :tax_categories, :tax_rates, :tax_settings, :zones, :countries, :states, :payment_methods, :taxonomies, :shipping_methods, :shipping_categories, :enterprise_fees, :contents, :invoice_settings, :matomo_settings, :stripe_connect_settings, label: 'configuration', icon: 'icon-wrench', url: edit_admin_general_settings_path -= tab :enterprises, :enterprise_relationships, :oidc_settings, url: main_app.admin_enterprises_path += tab :enterprises, :enterprise_relationships, :vouchers, :oidc_settings, url: main_app.admin_enterprises_path = tab :customers, url: main_app.admin_customers_path = tab :enterprise_groups, url: main_app.admin_enterprise_groups_path, label: 'groups' - if can? :admin, Spree::User diff --git a/app/webpacker/controllers/tabs_and_panels_controller.js b/app/webpacker/controllers/tabs_and_panels_controller.js index 0a1762cedb..e9b208fae7 100644 --- a/app/webpacker/controllers/tabs_and_panels_controller.js +++ b/app/webpacker/controllers/tabs_and_panels_controller.js @@ -12,13 +12,40 @@ export default class extends Controller { // only display the default panel this.defaultTarget.style.display = "block"; + + // Display panel specified in url anchor + const anchors = window.location.toString().split("#"); + let anchor = anchors.length > 1 ? anchors.pop() : ""; + + if (anchor != "") { + // Conveniently AngularJs rewrite "example.com#panel" to "example.com#/panel" :( + // strip the starting / if any + if (anchor[0] == "/") { + anchor = anchor.slice(1); + } + + this.updateActivePanel(anchor); + + // tab + const tab_id = anchor.split("_panel").shift(); + this.updateActiveTab(tab_id); + } } changeActivePanel(event) { + this.updateActivePanel(`${event.currentTarget.id}_panel`); + } + + updateActivePanel(panel_id) { const newActivePanel = this.panelTargets.find( - (panel) => panel.id == `${event.currentTarget.id}_panel` + (panel) => panel.id == panel_id ); + if (newActivePanel === undefined) { + // No panel found + return; + } + this.currentActivePanel.style.display = "none"; newActivePanel.style.display = "block"; } @@ -28,6 +55,18 @@ export default class extends Controller { event.currentTarget.classList.add(`${this.classNameValue}`); } + updateActiveTab(tab_id) { + const newActiveTab = this.tabTargets.find((tab) => tab.id == tab_id); + + if (newActiveTab === undefined) { + // No tab found + return; + } + + this.currentActiveTab.classList.remove(`${this.classNameValue}`); + newActiveTab.classList.add(`${this.classNameValue}`); + } + get currentActiveTab() { return this.tabTargets.find((tab) => tab.classList.contains("selected")); } diff --git a/config/locales/en.yml b/config/locales/en.yml index e69f2f8464..50b456fd18 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1143,6 +1143,18 @@ en: add_unregistered_user: "Add an unregistered user" email_confirmed: "Email confirmed" email_not_confirmed: "Email not confirmed" + vouchers: + legend: Vouchers + voucher_code: Voucher Code + rate: Rate + label: Label + purpose: Purpose + expiry: Expiry + use_limit: Use/Limit + customers: Customer + net_value: Net Value + add_new: Add New + no_voucher_yet: No Vouchers yet actions: edit_profile: Settings properties: Properties @@ -1381,6 +1393,7 @@ en: tag_rules: "Tag Rules" shop_preferences: "Shop Preferences" users: "Users" + vouchers: Vouchers enterprise_group: primary_details: "Primary Details" users: "Users" @@ -1589,6 +1602,13 @@ en: schedules: destroy: associated_subscriptions_error: This schedule cannot be deleted because it has associated subscriptions + vouchers: + new: + legend: New Voucher + back: Back + save: Save + voucher_code: Voucher Code + voucher_amount: Amount # Admin controllers controllers: diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b28f1eae57..69e2186cd0 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -40,6 +40,10 @@ Openfoodnetwork::Application.routes.draw do end resources :tag_rules, only: [:destroy] + + constraints FeatureToggleConstraint.new(:vouchers) do + resources :vouchers, only: [:new, :create] + end end resources :enterprise_relationships diff --git a/db/migrate/20230215034821_create_vouchers.rb b/db/migrate/20230215034821_create_vouchers.rb new file mode 100644 index 0000000000..acd2e247a7 --- /dev/null +++ b/db/migrate/20230215034821_create_vouchers.rb @@ -0,0 +1,12 @@ +class CreateVouchers < ActiveRecord::Migration[6.1] + def change + create_table :vouchers do |t| + t.string :code, null: false, limit: 255 + t.datetime :expiry_date + + t.timestamps + end + add_reference :vouchers, :enterprise, foreign_key: true + add_index :vouchers, [:code, :enterprise_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 803238dc31..6e7f939670 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1196,6 +1196,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_15_031807) do t.index ["user_id"], name: "index_webhook_endpoints_on_user_id" end + create_table "vouchers", force: :cascade do |t| + t.string "code", limit: 255, null: false + t.datetime "expiry_date" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "enterprise_id" + t.index ["code", "enterprise_id"], name: "index_vouchers_on_code_and_enterprise_id", unique: true + t.index ["enterprise_id"], name: "index_vouchers_on_enterprise_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "adjustment_metadata", "enterprises", name: "adjustment_metadata_enterprise_id_fk" @@ -1301,4 +1311,5 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_15_031807) do 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 "webhook_endpoints", "spree_users", column: "user_id" + add_foreign_key "vouchers", "enterprises" end diff --git a/lib/open_food_network/feature_toggle.rb b/lib/open_food_network/feature_toggle.rb index e0b8e49451..ebebffb1de 100644 --- a/lib/open_food_network/feature_toggle.rb +++ b/lib/open_food_network/feature_toggle.rb @@ -37,6 +37,9 @@ module OpenFoodNetwork "split_checkout" => <<~DESC, Replace the one-page checkout with a multi-step checkout. DESC + "vouchers" => <<~DESC, + Add voucher functionality. Voucher can be managed via Enterprise settings. + DESC }.freeze # Move your feature entry from CURRENT_FEATURES to RETIRED_FEATURES when diff --git a/spec/javascripts/stimulus/tabs_and_panels_controller_test.js b/spec/javascripts/stimulus/tabs_and_panels_controller_test.js index 8fdfedd117..523f5eb04f 100644 --- a/spec/javascripts/stimulus/tabs_and_panels_controller_test.js +++ b/spec/javascripts/stimulus/tabs_and_panels_controller_test.js @@ -2,16 +2,26 @@ * @jest-environment jsdom */ -import { Application } from "stimulus"; -import tabs_and_panels_controller from "../../../app/webpacker/controllers/tabs_and_panels_controller"; +import { Application } from 'stimulus'; +import tabs_and_panels_controller from '../../../app/webpacker/controllers/tabs_and_panels_controller'; -describe("EnterprisePanelController", () => { +describe('TabsAndPanelsController', () => { beforeAll(() => { const application = Application.start(); - application.register("tabs-and-panels", tabs_and_panels_controller); + application.register('tabs-and-panels', tabs_and_panels_controller); }); - describe("#tabs-and-panels", () => { + describe('#tabs-and-panels', () => { + const checkDefaultPanel = () => { + const peekPanel = document.getElementById('peek_panel'); + const kaPanel = document.getElementById('ka_panel'); + const booPanel = document.getElementById('boo_panel'); + + expect(peekPanel.style.display).toBe('block'); + expect(kaPanel.style.display).toBe('none'); + expect(booPanel.style.display).toBe('none'); + } + beforeEach(() => { document.body.innerHTML = `
@@ -26,23 +36,97 @@ describe("EnterprisePanelController", () => {
`; }); - it("displays only the default panel", () => { - const peekPanel = document.getElementById("peek_panel"); - const kaPanel = document.getElementById("ka_panel"); - const booPanel = document.getElementById("boo_panel"); - - expect(peekPanel.style.display).toBe("block"); - expect(kaPanel.style.display).toBe("none"); - expect(booPanel.style.display).toBe("none"); + it('displays only the default panel', () => { + checkDefaultPanel() }); - it("displays appropriate panel when associated tab is clicked", () => { - const kaPanel = document.getElementById("ka_panel"); - const ka = document.getElementById("ka"); + describe('when tab is clicked', () => { + let ka; - expect(kaPanel.style.display).toBe("none"); - ka.click(); - expect(kaPanel.style.display).toBe("block"); - }); + beforeEach(() => { + ka = document.getElementById('ka'); + }) + + it('displays appropriate panel', () => { + const kaPanel = document.getElementById('ka_panel'); + + expect(kaPanel.style.display).toBe('none'); + ka.click(); + expect(kaPanel.style.display).toBe('block'); + }); + + it('selects the clicked tab', () => { + ka.click(); + expect(ka.classList.contains('selected')).toBe(true); + }); + + describe("when panel doesn't exist", () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ Peek + Ka + Boo + + +
Peek me
+
Boo three
+
`; + }); + + it('displays the current panel', () => { + const peekPanel = document.getElementById('peek_panel'); + + ka.click(); + expect(peekPanel.style.display).toBe('block'); + }) + }) + }) + + describe('when anchor is specified in the url', () => { + const { location } = window; + const mockLocationToString = (panel) => { + // Mocking window.location.toString() + const url = `http://localhost:3000/admin/enterprises/great-shop/edit#/${panel}` + const mockedToString = jest.fn() + mockedToString.mockImplementation(() => (url)) + + delete window.location + window.location = { + toString: mockedToString + } + } + + beforeAll(() => { + mockLocationToString('ka_panel') + }) + + afterAll(() => { + // cleaning up + window.location = location + }) + + it('displays the panel associated with the anchor', () => { + const kaPanel = document.getElementById('ka_panel'); + + expect(kaPanel.style.display).toBe('block'); + }) + + it('selects the tab entry associated with the anchor', () => { + const ka = document.getElementById('ka'); + + expect(ka.classList.contains('selected')).toBe(true); + }) + + describe("when anchor doesn't macht any panel", () => { + beforeAll(() => { + mockLocationToString('random_panel') + }) + + it('displays the default panel', () => { + checkDefaultPanel() + }) + }) + }) }); }); diff --git a/spec/models/enterprise_spec.rb b/spec/models/enterprise_spec.rb index f6290a8687..67e1ac2284 100644 --- a/spec/models/enterprise_spec.rb +++ b/spec/models/enterprise_spec.rb @@ -24,6 +24,7 @@ describe Enterprise do it { is_expected.to have_many(:distributed_orders) } it { is_expected.to belong_to(:address) } it { is_expected.to belong_to(:business_address) } + it { is_expected.to have_many(:vouchers) } it "destroys enterprise roles upon its own demise" do e = create(:enterprise) @@ -740,13 +741,9 @@ describe Enterprise do it "assigns permalink when initialized" do allow(Enterprise).to receive(:find_available_permalink).and_return("available_permalink") expect(Enterprise).to receive(:find_available_permalink).with("Name To Turn Into A Permalink") - expect( - lambda { enterprise.send(:initialize_permalink) } - ).to change{ - enterprise.permalink - }.to( - "available_permalink" - ) + expect do + enterprise.send(:initialize_permalink) + end.to change { enterprise.permalink }.to("available_permalink") end describe "finding a permalink" do diff --git a/spec/models/spree/ability_spec.rb b/spec/models/spree/ability_spec.rb index be904d6bce..68b45fdbcd 100644 --- a/spec/models/spree/ability_spec.rb +++ b/spec/models/spree/ability_spec.rb @@ -788,6 +788,10 @@ describe Spree::Ability do is_expected.to have_ability([:admin, :known_users, :customers], for: :search) is_expected.not_to have_ability([:users], for: :search) end + + it "has the ability to manage vouchers" do + is_expected.to have_ability([:admin, :create], for: Voucher) + end end context 'enterprise owner' do diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb new file mode 100644 index 0000000000..9d9590455f --- /dev/null +++ b/spec/models/voucher_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Voucher do + describe 'associations' do + it { is_expected.to belong_to(:enterprise) } + end + + describe 'validations' do + subject { Voucher.new(code: 'new_code', enterprise: enterprise) } + + let(:enterprise) { build(:enterprise) } + + it { is_expected.to validate_presence_of(:code) } + it { is_expected.to validate_uniqueness_of(:code).scoped_to(:enterprise_id) } + end +end diff --git a/spec/system/admin/vouchers_spec.rb b/spec/system/admin/vouchers_spec.rb new file mode 100644 index 0000000000..7b18eec578 --- /dev/null +++ b/spec/system/admin/vouchers_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'system_helper' + +describe ' + As an entreprise user + I want to manage vouchers +' do + include WebHelper + include AuthenticationHelper + + let(:enterprise) { create(:supplier_enterprise, name: 'Feedme') } + let(:voucher_code) { 'awesomevoucher' } + let(:enterprise_user) { create(:user, enterprise_limit: 1) } + + before do + Flipper.enable(:vouchers) + + enterprise_user.enterprise_roles.build(enterprise: enterprise).save + login_as enterprise_user + end + + it 'lists enterprise vouchers' do + # Given an enterprise with vouchers + Voucher.create!(enterprise: enterprise, code: voucher_code) + + # When I go to the enterprise voucher tab + visit edit_admin_enterprise_path(enterprise) + + click_link 'Vouchers' + + # Then I see a list of vouchers + expect(page).to have_content voucher_code + expect(page).to have_content "10" + end + + it 'creates a voucher' do + # Given an enterprise + # When I go to the enterprise voucher tab and click new + visit edit_admin_enterprise_path(enterprise) + + click_link 'Vouchers' + within "#vouchers_panel" do + click_link 'Add New' + end + + # And I fill in the fields for a new voucher click save + fill_in 'voucher_code', with: voucher_code + click_button 'Save' + + # Then I should get redirect to the entreprise voucher tab and see the created voucher + expect(page).to have_selector '.success', text: 'Voucher has been successfully created!' + expect(page).to have_content voucher_code + expect(page).to have_content "10" + + voucher = Voucher.where(enterprise: enterprise, code: voucher_code).first + + expect(voucher).not_to be(nil) + end + + context 'when entering invalid data' do + it 'shows an error flash message' do + # Given an enterprise + # When I go to the new voucher page + visit new_admin_enterprise_voucher_path(enterprise) + + # And I fill in fields with invalid data and click save + click_button 'Save' + + # Then I should see an error flash message + expect(page).to have_selector '.error', text: "Code can't be blank" + + vouchers = Voucher.where(enterprise: enterprise) + + expect(vouchers).to be_empty + end + end +end