Merge pull request #10523 from rioug/10431-voucher-minimum-backend

Voucher bare minimum backoffice
This commit is contained in:
Filipe
2023-03-29 20:21:57 +01:00
committed by GitHub
21 changed files with 419 additions and 30 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

15
app/models/voucher.rb Normal file
View File

@@ -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

View File

@@ -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")

View File

@@ -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')

View File

@@ -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] }")

View File

@@ -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

View File

@@ -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

View File

@@ -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"));
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = `
<div data-controller="tabs-and-panels" data-tabs-and-panels-class-name-value="selected">
@@ -26,23 +36,97 @@ describe("EnterprisePanelController", () => {
</div>`;
});
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 = `
<div data-controller="tabs-and-panels" data-tabs-and-panels-class-name-value="selected">
<a id="peek" href="#" data-action="tabs-and-panels#changeActivePanel tabs-and-panels#changeActiveTab" class="selected" data-tabs-and-panels-target="tab">Peek</a>
<a id="ka" href="#" data-action="tabs-and-panels#changeActivePanel tabs-and-panels#changeActiveTab" data-tabs-and-panels-target="tab">Ka</a>
<a id="boo" href="#" data-action="tabs-and-panels#changeActivePanel tabs-and-panels#changeActiveTab" data-tabs-and-panels-target="tab">Boo</a>
<div id="peek_panel" data-tabs-and-panels-target="panel default">Peek me</div>
<div id="boo_panel" data-tabs-and-panels-target="panel">Boo three</div>
</div>`;
});
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()
})
})
})
});
});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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