Merge pull request #13823 from chahmedejaz/bugfix/13625-enterprise-fee-reports-throws-504

Some Enterprise Fee reports are unusable when managing big shops
This commit is contained in:
Rachel Arnould
2026-02-18 15:22:21 +01:00
committed by GitHub
15 changed files with 955 additions and 71 deletions

View File

@@ -1,17 +1,18 @@
# frozen_string_literal: true
class SearchableDropdownComponent < ViewComponent::Base
REMOVED_SEARCH_PLUGIN = { 'tom-select-options-value': '{ "plugins": [] }' }.freeze
MINIMUM_OPTIONS_FOR_SEARCH_FIELD = 11 # at least 11 options are required for the search field
def initialize(
form:,
name:,
options:,
selected_option:,
placeholder_value:,
form: nil,
placeholder_value: '',
include_blank: false,
aria_label: '',
multiple: false,
remote_url: nil,
other_attrs: {}
)
@f = form
@@ -21,13 +22,15 @@ class SearchableDropdownComponent < ViewComponent::Base
@placeholder_value = placeholder_value
@include_blank = include_blank
@aria_label = aria_label
@multiple = multiple
@remote_url = remote_url
@other_attrs = other_attrs
end
private
attr_reader :f, :name, :options, :selected_option, :placeholder_value, :include_blank,
:aria_label, :other_attrs
:aria_label, :multiple, :remote_url, :other_attrs
def classes
"fullwidth #{'no-input' if remove_search_plugin?}"
@@ -36,11 +39,33 @@ class SearchableDropdownComponent < ViewComponent::Base
def data
{
controller: "tom-select",
'tom-select-placeholder-value': placeholder_value
}.merge(remove_search_plugin? ? REMOVED_SEARCH_PLUGIN : {})
'tom-select-placeholder-value': placeholder_value,
'tom-select-options-value': tom_select_options_value,
'tom-select-remote-url-value': remote_url,
}
end
def tom_select_options_value
plugins = []
plugins << 'virtual_scroll' if @remote_url.present?
plugins << 'dropdown_input' unless remove_search_plugin?
plugins << 'remove_button' if multiple
{
plugins:,
maxItems: multiple ? nil : 1,
}
end
def uses_form_builder?
f.present?
end
def remove_search_plugin?
@remove_search_plugin ||= options.count < MINIMUM_OPTIONS_FOR_SEARCH_FIELD
# Remove the search plugin when:
# - the select is multiple (it already includes a search field), or
# - there is no remote URL and the options are below the search threshold
@remove_search_plugin ||= multiple ||
(@remote_url.nil? && options.count < MINIMUM_OPTIONS_FOR_SEARCH_FIELD)
end
end

View File

@@ -1 +1,4 @@
= f.select name, options_for_select(options, selected_option), { include_blank: }, class: classes, data:, 'aria-label': aria_label, **other_attrs
- if uses_form_builder?
= f.select name, options, { selected: selected_option, include_blank:, multiple: }, class: classes, data:, 'aria-label': aria_label, **other_attrs
- else
= select_tag name, options_for_select(options, selected_option), include_blank:, multiple:, class: classes, data:, 'aria-label': aria_label, **other_attrs

View File

@@ -4,6 +4,7 @@ module Admin
class ReportsController < Spree::Admin::BaseController
include ActiveStorage::SetCurrent
include ReportsActions
include Reports::AjaxSearch
helper ReportsHelper

View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
module Reports
module AjaxSearch
extend ActiveSupport::Concern
def search_enterprise_fees
report = report_class.new(spree_current_user, params, render: false)
fee_ids = enterprise_fee_ids(report.search.result)
query = EnterpriseFee.where(id: fee_ids)
render json: build_search_response(query)
end
def search_enterprise_fee_owners
report = report_class.new(spree_current_user, params, render: false)
owner_ids = enterprise_fee_owner_ids(report.search.result)
query = Enterprise.where(id: owner_ids)
render json: build_search_response(query)
end
def search_distributors
query = frontend_data.distributors
render json: build_search_response(query)
end
def search_order_cycles
query = frontend_data.order_cycles
render json: build_search_response(query)
end
def search_order_customers
query = frontend_data.order_customers
render json: build_search_response(query)
end
def search_suppliers
query = frontend_data.orders_suppliers
render json: build_search_response(query)
end
private
def build_search_response(query)
page = (params[:page] || 1).to_i
per_page = 30
filtered_query = apply_search_filter(query)
total_count = filtered_query.size
items = paginated_items(filtered_query, page, per_page)
results = format_results(items)
{ results: results, pagination: { more: (page * per_page) < total_count } }
end
def apply_search_filter(query)
search_term = params[:q]
return query if search_term.blank?
escaped_search_term = ActiveRecord::Base.sanitize_sql_like(search_term)
pattern = "%#{escaped_search_term}%"
# Handle different model types
if query.model == OrderCycle
query.where("order_cycles.name ILIKE ?", pattern)
elsif query.model == Customer
query.where("customers.email ILIKE ?", pattern)
else
query.where("name ILIKE ?", pattern)
end
end
def paginated_items(query, page, per_page)
if query.model == Customer
query.order(:email).offset((page - 1) * per_page).limit(per_page).pluck(:email, :id)
elsif query.model == OrderCycle
query.order('order_cycles.orders_close_at DESC')
.offset((page - 1) * per_page)
.limit(per_page).pluck(
:name, :id
)
else
query.order(:name).offset((page - 1) * per_page).limit(per_page).pluck(:name, :id)
end
end
def format_results(items)
items.map { |label, value| { value:, label: } }
end
def frontend_data
@frontend_data ||= Reporting::FrontendData.new(spree_current_user)
end
def enterprise_fee_owner_ids(orders)
EnterpriseFee.where(id: enterprise_fee_ids(orders)).select(:enterprise_id)
end
def enterprise_fee_ids(orders)
Spree::Adjustment.enterprise_fee.where(order_id: orders.select(:id)).select(:originator_id)
end
end
end

View File

@@ -34,29 +34,8 @@ module ReportsHelper
end
end
def fee_name_options(orders)
EnterpriseFee.where(id: enterprise_fee_ids(orders))
.pluck(:name, :id)
end
def fee_owner_options(orders)
Enterprise.where(id: enterprise_fee_owner_ids(orders))
.pluck(:name, :id)
end
delegate :currency_symbol, to: :'Spree::Money'
def enterprise_fee_owner_ids(orders)
EnterpriseFee.where(id: enterprise_fee_ids(orders))
.pluck(:enterprise_id)
end
def enterprise_fee_ids(orders)
Spree::Adjustment.enterprise_fee
.where(order_id: orders.map(&:id))
.pluck(:originator_id)
end
def datepicker_time(datetime)
datetime = Time.zone.parse(datetime) if datetime.is_a? String
datetime.strftime('%Y-%m-%d %H:%M')

View File

@@ -6,6 +6,11 @@ module Spree
class Ability
include CanCan::Ability
REPORTS_SEARCH_ACTIONS = [
:search_enterprise_fees, :search_enterprise_fee_owners, :search_distributors,
:search_suppliers, :search_order_cycles, :search_order_customers
].freeze
def initialize(user)
clear_aliased_actions
@@ -260,7 +265,8 @@ module Spree
can [:admin, :index, :import], ::Admin::DfcProductImportsController
# Reports page
can [:admin, :index, :show, :create], ::Admin::ReportsController
can [:admin, :index, :show, :create, *REPORTS_SEARCH_ACTIONS],
::Admin::ReportsController
can [:admin, :show, :create, :customers, :orders_and_distributors, :group_buys, :payments,
:orders_and_fulfillment, :products_and_inventory, :order_cycle_management,
:packing, :enterprise_fee_summary, :bulk_coop, :suppliers], :report
@@ -392,7 +398,7 @@ module Spree
end
# Reports page
can [:admin, :index, :show, :create], ::Admin::ReportsController
can [:admin, :index, :show, :create, *REPORTS_SEARCH_ACTIONS], ::Admin::ReportsController
can [:admin, :customers, :group_buys, :sales_tax, :payments,
:orders_and_distributors, :orders_and_fulfillment, :products_and_inventory,
:order_cycle_management, :xero_invoices, :enterprise_fee_summary, :bulk_coop], :report

View File

@@ -1,23 +1,50 @@
- search_url_query = {report_type: :enterprise_fee_summary, report_subtype: :enterprise_fees_with_tax_report_by_order}
.row
.alpha.two.columns= label_tag nil, t(:report_hubs)
.omega.fourteen.columns= f.collection_select(:distributor_id_in, @data.distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true})
.omega.fourteen.columns
= render(SearchableDropdownComponent.new(form: f,
name: :distributor_id_in,
options: [],
selected_option: params.dig(:q, :distributor_id_in),
multiple: true,
remote_url: admin_reports_search_distributors_url))
.row
.alpha.two.columns= label_tag nil, t(:report_customers_cycle)
.omega.fourteen.columns
= f.select(:order_cycle_id_in, report_order_cycle_options(@data.order_cycles), {selected: params.dig(:q, :order_cycle_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :order_cycle_id_in,
options: [],
selected_option: params.dig(:q, :order_cycle_id_in),
multiple: true,
remote_url: admin_reports_search_order_cycles_url))
.row
.alpha.two.columns= label_tag nil, t(:fee_name)
.omega.fourteen.columns
= f.select(:enterprise_fee_id_in, fee_name_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :enterprise_fee_id_in,
options: [],
selected_option: params.dig(:q, :enterprise_fee_id_in),
multiple: true,
remote_url: admin_reports_search_enterprise_fees_url(search_url_query)))
.row
.alpha.two.columns= label_tag nil, t(:fee_owner)
.omega.fourteen.columns
= f.select(:enterprise_fee_owner_id_in, fee_owner_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :enterprise_fee_owner_id_in,
options: [],
selected_option: params.dig(:q, :enterprise_fee_owner_id_in),
multiple: true,
remote_url: admin_reports_search_enterprise_fee_owners_url(search_url_query)))
.row
.alpha.two.columns= label_tag nil, t(:report_customers)
.omega.fourteen.columns
= f.select(:customer_id_in, customer_email_options(@data.order_customers), {selected: params.dig(:q, :customer_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :customer_id_in,
options: [],
selected_option: params.dig(:q, :customer_id_in),
multiple: true,
remote_url: admin_reports_search_order_customers_url))

View File

@@ -1,27 +1,57 @@
- search_url_query = {report_type: :enterprise_fee_summary, report_subtype: :enterprise_fees_with_tax_report_by_producer}
.row
.alpha.two.columns= label_tag nil, t(:report_hubs)
.omega.fourteen.columns= f.collection_select(:distributor_id_in, @data.distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true})
.omega.fourteen.columns
= render(SearchableDropdownComponent.new(form: f,
name: :distributor_id_in,
options: [],
selected_option: params.dig(:q, :distributor_id_in),
multiple: true,
remote_url: admin_reports_search_distributors_url))
.row
.alpha.two.columns= label_tag nil, t(:report_producers)
.omega.fourteen.columns= select_tag(:supplier_id_in, options_from_collection_for_select(@data.orders_suppliers, :id, :name, params[:supplier_id_in]), {class: "select2 fullwidth", multiple: true})
.omega.fourteen.columns
= render(SearchableDropdownComponent.new(name: :supplier_id_in,
options: [],
selected_option: params.dig(:supplier_id_in),
multiple: true,
remote_url: admin_reports_search_suppliers_url))
.row
.alpha.two.columns= label_tag nil, t(:report_customers_cycle)
.omega.fourteen.columns
= f.select(:order_cycle_id_in, report_order_cycle_options(@data.order_cycles), {selected: params.dig(:q, :order_cycle_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :order_cycle_id_in,
options: [],
selected_option: params.dig(:q, :order_cycle_id_in),
multiple: true,
remote_url: admin_reports_search_order_cycles_url))
.row
.alpha.two.columns= label_tag nil, t(:fee_name)
.omega.fourteen.columns
= f.select(:enterprise_fee_id_in, fee_name_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :enterprise_fee_id_in,
options: [],
selected_option: params.dig(:q, :enterprise_fee_id_in),
multiple: true,
remote_url: admin_reports_search_enterprise_fees_url(search_url_query)))
.row
.alpha.two.columns= label_tag nil, t(:fee_owner)
.omega.fourteen.columns
= f.select(:enterprise_fee_owner_id_in, fee_owner_options(@report.search.result), {selected: params.dig(:q, :enterprise_fee_owner_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :enterprise_fee_owner_id_in,
options: [],
selected_option: params.dig(:q, :enterprise_fee_owner_id_in),
multiple: true,
remote_url: admin_reports_search_enterprise_fee_owners_url(search_url_query)))
.row
.alpha.two.columns= label_tag nil, t(:report_customers)
.omega.fourteen.columns
= f.select(:customer_id_in, customer_email_options(@data.order_customers), {selected: params.dig(:q, :customer_id_in)}, {class: "select2 fullwidth", multiple: true})
= render(SearchableDropdownComponent.new(form: f,
name: :customer_id_in,
options: [],
selected_option: params.dig(:q, :customer_id_in),
multiple: true,
remote_url: admin_reports_search_order_customers_url))

View File

@@ -1,11 +1,16 @@
import { Controller } from "stimulus";
import TomSelect from "tom-select/dist/esm/tom-select.complete";
import showHttpError from "../../webpacker/js/services/show_http_error";
export default class extends Controller {
static values = { options: Object, placeholder: String };
static values = {
options: Object,
placeholder: String,
remoteUrl: String,
};
connect(options = {}) {
this.control = new TomSelect(this.element, {
let tomSelectOptions = {
maxItems: 1,
maxOptions: null,
plugins: ["dropdown_input"],
@@ -16,7 +21,13 @@ export default class extends Controller {
},
...this.optionsValue,
...options,
});
};
if (this.remoteUrlValue) {
this.#addRemoteOptions(tomSelectOptions);
}
this.control = new TomSelect(this.element, tomSelectOptions);
}
disconnect() {
@@ -29,4 +40,78 @@ export default class extends Controller {
const optionsArray = [...this.element.options];
return optionsArray.find((option) => [null, ""].includes(option.value))?.text;
}
#buildUrl(query, page = 1) {
const url = new URL(this.remoteUrlValue, window.location.origin);
url.searchParams.set("q", query);
url.searchParams.set("page", page);
return url.toString();
}
#fetchOptions(query, callback) {
const url = this.control.getUrl(query);
fetch(url)
.then((response) => {
if (!response.ok) {
showHttpError(response.status);
throw response;
}
return response.json();
})
.then((json) => {
/**
* Expected API shape:
* {
* results: [{ value, label }],
* pagination: { more: boolean }
* }
*/
if (json.pagination?.more) {
const currentUrl = new URL(url);
const currentPage = parseInt(currentUrl.searchParams.get("page") || "1");
const nextUrl = this.#buildUrl(query, currentPage + 1);
this.control.setNextUrl(query, nextUrl);
}
callback(json.results || []);
})
.catch((error) => {
callback();
console.error(error);
});
}
#addRemoteOptions(options) {
this.openedByClick = false;
options.firstUrl = (query) => {
return this.#buildUrl(query, 1);
};
options.load = this.#fetchOptions.bind(this);
options.onFocus = function () {
this.control.load("", () => {});
}.bind(this);
options.onDropdownOpen = function () {
this.openedByClick = true;
}.bind(this);
options.onType = function () {
this.openedByClick = false;
}.bind(this);
// As per TomSelect source code, Loading state is shown on the UI when this function returns true.
// By default it shows loading state only when there is some input in the search box.
// We want to show loading state on focus as well (when there is no input) to indicate that options are being loaded.
options.shouldLoad = function (query) {
return this.openedByClick || query.length > 0;
}.bind(this);
options.valueField = "value";
options.labelField = "label";
options.searchField = "label";
}
}

View File

@@ -136,7 +136,15 @@ Openfoodnetwork::Application.routes.draw do
put :resume, on: :member, format: :json
end
get '/reports', to: 'reports#index', as: :reports
scope :reports, as: :reports do
get '/', to: 'reports#index'
get '/search_enterprise_fees', to: 'reports#search_enterprise_fees', as: :search_enterprise_fees
get '/search_enterprise_fee_owners', to: 'reports#search_enterprise_fee_owners', as: :search_enterprise_fee_owners
get '/search_distributors', to: 'reports#search_distributors', as: :search_distributors
get '/search_suppliers', to: 'reports#search_suppliers', as: :search_suppliers
get '/search_order_cycles', to: 'reports#search_order_cycles', as: :search_order_cycles
get '/search_order_customers', to: 'reports#search_order_customers', as: :search_order_customers
end
match '/reports/:report_type(/:report_subtype)', to: 'reports#show', via: :get, as: :report
match '/reports/:report_type(/:report_subtype)', to: 'reports#create', via: :post
end

View File

@@ -179,7 +179,7 @@ module.exports = {
// transform: { "\\.[jt]sx?$": "babel-jest" },
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/(?!stimulus)/"],
transformIgnorePatterns: ["/node_modules/(?!(stimulus.+|tom-select)/)"],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

View File

@@ -46,7 +46,7 @@ module Reporting
end
def order_customers
Customer.where(id: visible_order_customer_ids).select("customers.id, customers.email")
Customer.where(id: visible_order_customer_ids_query).select("customers.id, customers.email")
end
private
@@ -57,8 +57,8 @@ module Reporting
@permissions ||= OpenFoodNetwork::Permissions.new(current_user)
end
def visible_order_customer_ids
Permissions::Order.new(current_user).visible_orders.pluck(:customer_id)
def visible_order_customer_ids_query
Permissions::Order.new(current_user).visible_orders.select(:customer_id)
end
end
end

View File

@@ -0,0 +1,296 @@
/**
* @jest-environment jsdom
*/
import { Application } from "stimulus";
import { fireEvent, waitFor } from "@testing-library/dom";
import tom_select_controller from "controllers/tom_select_controller";
import showHttpError from "js/services/show_http_error";
jest.mock("js/services/show_http_error", () => ({
__esModule: true,
default: jest.fn(),
}));
/* ------------------------------------------------------------------
* Helpers
* ------------------------------------------------------------------ */
const buildResults = (count, start = 1) =>
Array.from({ length: count }, (_, i) => ({
value: String(start + i),
label: `Option ${start + i}`,
}));
const setupDOM = (html) => {
document.body.innerHTML = html;
};
const getSelect = () => document.getElementById("select");
const getTomSelect = () => getSelect().tomselect;
const openDropdown = () => fireEvent.click(document.getElementById("select-ts-control"));
const mockRemoteFetch = (...responses) => {
responses.forEach((response) => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => response,
});
});
};
const mockDropdownScroll = (
dropdown,
{ scrollHeight = 1000, clientHeight = 300, scrollTop = 700 } = {},
) => {
Object.defineProperty(dropdown, "scrollHeight", {
configurable: true,
value: scrollHeight,
});
Object.defineProperty(dropdown, "clientHeight", {
configurable: true,
value: clientHeight,
});
Object.defineProperty(dropdown, "scrollTop", {
configurable: true,
writable: true,
value: scrollTop,
});
fireEvent.scroll(dropdown);
};
/* ------------------------------------------------------------------
* Expectation helpers
* ------------------------------------------------------------------ */
const expectOptionsCount = (count) => {
expect(document.querySelectorAll('.ts-dropdown-content [role="option"]').length).toBe(count);
};
const expectDropdownToContain = (text) => {
expect(document.querySelector(".ts-dropdown-content")?.textContent).toContain(text);
};
const expectDropdownWithNoResults = () => {
expect(document.querySelector(".ts-dropdown-content")?.textContent).toBe("No results found");
};
/* ------------------------------------------------------------------
* Specs
* ------------------------------------------------------------------ */
describe("TomSelectController", () => {
let application;
beforeAll(() => {
application = Application.start();
application.register("tom-select", tom_select_controller);
});
beforeEach(() => {
global.fetch = jest.fn();
global.I18n = { t: (key) => key };
});
afterEach(() => {
document.body.innerHTML = "";
jest.clearAllMocks();
});
describe("connect()", () => {
beforeEach(() => {
setupDOM(`
<select id="select" data-controller="tom-select">
<option value="">Default Option</option>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
`);
});
it("initializes TomSelect with default options", () => {
const settings = getTomSelect().settings;
expect(settings.placeholder).toBe("Default Option");
expect(settings.maxItems).toBe(1);
expect(settings.plugins).toEqual(["dropdown_input"]);
expect(settings.allowEmptyOption).toBe(true);
});
});
describe("connect() with custom values", () => {
beforeEach(() => {
setupDOM(`
<select
id="select"
data-controller="tom-select"
data-tom-select-placeholder-value="Choose an option"
data-tom-select-options-value='{"maxItems": 3, "plugins": ["remove_button"]}'
>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
</select>
`);
});
it("applies custom placeholder and options", () => {
const settings = getTomSelect().settings;
expect(settings.placeholder).toBe("Choose an option");
expect(settings.maxItems).toBe(3);
expect(settings.plugins).toEqual(["remove_button"]);
});
});
describe("connect() with remoteUrl", () => {
beforeEach(() => {
setupDOM(`
<select
id="select"
data-controller="tom-select"
data-tom-select-options-value='{"plugins":["virtual_scroll"]}'
data-tom-select-remote-url-value="https://ofn-tests.com/api/search"
></select>
`);
});
it("configures remote loading callbacks", () => {
const settings = getTomSelect().settings;
expect(settings.valueField).toBe("value");
expect(settings.labelField).toBe("label");
expect(settings.searchField).toBe("label");
expect(settings.load).toEqual(expect.any(Function));
expect(settings.firstUrl).toEqual(expect.any(Function));
expect(settings.onFocus).toEqual(expect.any(Function));
});
it("fetches page 1 on focus", async () => {
mockRemoteFetch({
results: buildResults(1),
pagination: { more: false },
});
openDropdown();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=&page=1"));
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Option 1");
});
});
it("fetches remote options using search query", async () => {
const appleOption = { value: "apple", label: "Apple" };
mockRemoteFetch({
results: [...buildResults(1), appleOption],
pagination: { more: false },
});
openDropdown();
await waitFor(() => {
expectOptionsCount(2);
});
mockRemoteFetch({
results: [appleOption],
pagination: { more: false },
});
fireEvent.input(document.getElementById("select-ts-control"), {
target: { value: "apple" },
});
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("q=apple&page=1")),
);
await waitFor(() => {
expectOptionsCount(1);
expectDropdownToContain("Apple");
});
});
it("loads next page on scroll (infinite scroll)", async () => {
mockRemoteFetch(
{
results: buildResults(30),
pagination: { more: true },
},
{
results: buildResults(1, 31),
pagination: { more: false },
},
);
openDropdown();
await waitFor(() => {
expectOptionsCount(30);
});
const dropdown = document.querySelector(".ts-dropdown-content");
mockDropdownScroll(dropdown);
await waitFor(() => {
expectOptionsCount(31);
});
expect(fetch).toHaveBeenCalledTimes(2);
});
it("handles fetch errors gracefully", async () => {
fetch.mockRejectedValueOnce(new Error("Fetch error"));
openDropdown();
await waitFor(() => {
expectDropdownWithNoResults();
});
expect(showHttpError).not.toHaveBeenCalled();
});
it("displays HTTP error on failure", async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
openDropdown();
await waitFor(() => {
expect(showHttpError).toHaveBeenCalledWith(500);
});
expectDropdownWithNoResults();
});
it("controls loading behavior based on user interaction", () => {
const settings = getTomSelect().settings;
// Initial state: openedByClick is false, query is empty
expect(settings.shouldLoad("")).toBe(false);
// Simulating opening the dropdown
settings.onDropdownOpen();
expect(settings.shouldLoad("")).toBe(true);
// Simulating typing
settings.onType();
expect(settings.shouldLoad("")).toBe(false);
// Query present
expect(settings.shouldLoad("a")).toBe(true);
});
});
});

View File

@@ -0,0 +1,328 @@
# frozen_string_literal: true
RSpec.describe "Admin Reports AJAX Search API" do
let(:bill_address) { create(:address) }
let(:ship_address) { create(:address) }
let(:instructions) { "pick up on thursday please" }
let(:coordinator1) { create(:distributor_enterprise) }
let(:supplier1) { create(:supplier_enterprise) }
let(:supplier2) { create(:supplier_enterprise) }
let(:supplier3) { create(:supplier_enterprise) }
let(:distributor1) { create(:distributor_enterprise) }
let(:distributor2) { create(:distributor_enterprise) }
let(:product1) { create(:product, price: 12.34, supplier_id: supplier1.id) }
let(:product2) { create(:product, price: 23.45, supplier_id: supplier2.id) }
let(:product3) { create(:product, price: 34.56, supplier_id: supplier3.id) }
let(:enterprise_fee1) { create(:enterprise_fee, name: "Delivery Fee", enterprise: distributor1) }
let(:enterprise_fee2) { create(:enterprise_fee, name: "Admin Fee", enterprise: distributor2) }
let(:ocA) {
create(:simple_order_cycle, coordinator: coordinator1,
distributors: [distributor1, distributor2],
suppliers: [supplier1, supplier2, supplier3],
variants: [product1.variants.first, product3.variants.first])
}
let(:ocB) {
create(:simple_order_cycle, coordinator: coordinator1,
distributors: [distributor1, distributor2],
suppliers: [supplier1, supplier2, supplier3],
variants: [product2.variants.first])
}
let(:orderA1) do
order = create(:order, distributor: distributor1, bill_address:,
ship_address:, special_instructions: instructions,
order_cycle: ocA)
order.line_items << create(:line_item, variant: product1.variants.first)
order.line_items << create(:line_item, variant: product3.variants.first)
order.finalize!
order.save
order
end
let(:orderA2) do
order = create(:order, distributor: distributor2, bill_address:,
ship_address:, special_instructions: instructions,
order_cycle: ocA)
order.line_items << create(:line_item, variant: product2.variants.first)
order.finalize!
order.save
order
end
let(:orderB1) do
order = create(:order, distributor: distributor1, bill_address:,
ship_address:, special_instructions: instructions,
order_cycle: ocB)
order.line_items << create(:line_item, variant: product1.variants.first)
order.line_items << create(:line_item, variant: product3.variants.first)
order.finalize!
order.save
order
end
let(:base_params) do
{
report_type: :enterprise_fee_summary,
report_subtype: :enterprise_fees_with_tax_report_by_order
}
end
def create_adjustment(order, fee, amount)
order.adjustments.create!(
originator: fee,
label: fee.name,
amount:,
state: "finalized",
order:
)
end
context "when user is an admin" do
before do
login_as create(:admin_user)
create_adjustment(orderA1, enterprise_fee1, 5.0)
create_adjustment(orderB1, enterprise_fee2, 3.0)
end
describe "GET /admin/reports/search_enterprise_fees" do
it "returns enterprise fees sorted alphabetically by name" do
get "/admin/reports/search_enterprise_fees", params: base_params
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Admin Fee', 'Delivery Fee'])
expect(json_response["pagination"]["more"]).to be false
end
context "with more than 30 records" do
before do
create_list(:enterprise_fee, 35, enterprise: distributor1) do |fee, i|
index = (i + 1).to_s.rjust(2, "0")
fee.update!(name: "Fee #{index}")
create_adjustment(orderA1, fee, 1.0)
end
end
it "returns first page with 30 results and more flag as true" do
get "/admin/reports/search_enterprise_fees", params: base_params.merge(page: 1)
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get "/admin/reports/search_enterprise_fees", params: base_params.merge(page: 2)
json_response = response.parsed_body
expect(json_response["results"].length).to eq(7)
expect(json_response["pagination"]["more"]).to be false
end
end
end
describe "GET /admin/reports/search_enterprise_fee_owners" do
it "returns unique enterprise fee owners sorted alphabetically by name" do
distributor1.update!(name: "Zebra Farm")
distributor2.update!(name: "Alpha Market")
get "/admin/reports/search_enterprise_fee_owners", params: base_params
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Alpha Market', 'Zebra Farm'])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_order_customers" do
let!(:customer1) { create(:customer, email: "alice@example.com", enterprise: distributor1) }
let!(:customer2) { create(:customer, email: "bob@example.com", enterprise: distributor1) }
before do
orderA1.update!(customer: customer1)
orderA2.update!(customer: customer2)
end
it "returns all customers sorted by email" do
get "/admin/reports/search_order_customers", params: base_params
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["alice@example.com",
"bob@example.com"])
expect(json_response["pagination"]["more"]).to be false
end
it "filters customers by email query" do
get "/admin/reports/search_order_customers", params: base_params.merge(q: "alice")
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["alice@example.com"])
expect(json_response["pagination"]["more"]).to be false
end
context "with more than 30 customers" do
before do
create_list(:customer, 35, enterprise: distributor1) do |customer, i|
customer.update!(email: "customer#{(i + 1).to_s.rjust(2, '0')}@example.com")
order = create(:order, distributor: distributor1, order_cycle: ocA, customer:)
order.line_items << create(:line_item, variant: product1.variants.first)
order.finalize!
end
end
it "returns first page with 30 results and more flag as true" do
get "/admin/reports/search_order_customers", params: base_params.merge(page: 1)
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get "/admin/reports/search_order_customers", params: base_params.merge(page: 2)
json_response = response.parsed_body
expect(json_response["results"].length).to eq(7)
expect(json_response["pagination"]["more"]).to be false
end
end
end
describe "GET /admin/reports/search_order_cycles" do
before do
ocA.update!(name: "Winter Market")
ocB.update!(name: "Summer Market")
end
it "returns order cycles sorted by close date" do
get "/admin/reports/search_order_cycles", params: base_params
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["Summer Market", "Winter Market"])
expect(json_response["pagination"]["more"]).to be false
end
it "filters order cycles by name query" do
get "/admin/reports/search_order_cycles", params: base_params.merge(q: "Winter")
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["Winter Market"])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_distributors" do
before do
distributor1.update!(name: "Alpha Farm")
distributor2.update!(name: "Beta Market")
end
it "filters distributors by name query" do
get "/admin/reports/search_distributors", params: base_params.merge(q: "Alpha")
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["Alpha Farm"])
expect(json_response["pagination"]["more"]).to be false
end
context "with more than 30 distributors" do
before { create_list(:distributor_enterprise, 35) }
it "returns first page with 30 results and more flag as true" do
get "/admin/reports/search_distributors", params: base_params.merge(page: 1)
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on subsequent pages with more flag as false" do
get "/admin/reports/search_distributors", params: base_params.merge(page: 2)
json_response = response.parsed_body
expect(json_response["results"].length).to be > 0
expect(json_response["pagination"]["more"]).to be false
end
end
end
end
context "when user is not an admin" do
before do
login_as distributor1.users.first
create_adjustment(orderA1, enterprise_fee1, 5.0)
create_adjustment(orderA2, enterprise_fee2, 3.0)
end
describe "GET /admin/reports/search_enterprise_fees" do
it "returns only enterprise fees for user's managed enterprises" do
get "/admin/reports/search_enterprise_fees", params: base_params
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Delivery Fee'])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_enterprise_fee_owners" do
it "returns only enterprise fee owners for user's managed enterprises" do
get "/admin/reports/search_enterprise_fee_owners", params: base_params
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq([distributor1.name])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_order_customers" do
it "returns only customers from user's managed enterprises" do
customer1 = create(:customer, email: "alice@example.com", enterprise: distributor1)
customer2 = create(:customer, email: "bob@example.com", enterprise: distributor1)
orderA1.update!(customer: customer1)
orderA2.update!(customer: customer2)
get "/admin/reports/search_order_customers", params: base_params
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["alice@example.com"])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_order_cycles" do
it "returns only order cycles accessible to user's managed enterprises" do
ocA.update!(name: "Winter Market")
ocB.update!(name: "Summer Market")
create(:simple_order_cycle, name: 'Autumn Market', coordinator: coordinator1,
distributors: [distributor2],
suppliers: [supplier1, supplier2, supplier3],
variants: [product2.variants.first])
get "/admin/reports/search_order_cycles", params: base_params
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(["Summer Market", "Winter Market"])
expect(json_response["pagination"]["more"]).to be false
end
end
describe "GET /admin/reports/search_distributors" do
it "returns only user's managed distributors" do
get "/admin/reports/search_distributors", params: base_params
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq([distributor1.name])
expect(json_response["pagination"]["more"]).to be false
end
end
end
end

View File

@@ -296,11 +296,8 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
end
it "should filter by distributor and order cycle" do
page.find("#s2id_autogen1").click
find('li', text: distributor.name).click # selects Distributor
page.find("#s2id_q_order_cycle_id_in").click
find('li', text: order_cycle.name).click
tomselect_multiselect distributor.name, from: 'q[distributor_id_in][]'
tomselect_multiselect order_cycle.name, from: 'q[order_cycle_id_in][]'
run_report
expect(page.find("table.report__table thead tr")).to have_content(table_header)
@@ -455,9 +452,6 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
}
context "filtering" do
let(:fee_name_selector){ "#s2id_q_enterprise_fee_id_in" }
let(:fee_owner_selector){ "#s2id_q_enterprise_fee_owner_id_in" }
let(:summary_row_after_filtering_by_fee_name){
[cost_of_produce1, "TOTAL", "120.0", "4.8", "124.8"].join(" ")
}
@@ -471,11 +465,8 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
end
it "should filter by distributor and order cycle" do
page.find("#s2id_autogen1").click
find('li', text: distributor.name).click # selects Distributor
page.find("#s2id_q_order_cycle_id_in").click
find('li', text: order_cycle3.name).click
tomselect_multiselect distributor.name, from: 'q[distributor_id_in][]'
tomselect_multiselect order_cycle3.name, from: 'q[order_cycle_id_in][]'
run_report
expect(page.find("table.report__table thead tr")).to have_content(table_header)
@@ -504,8 +495,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
end
it "should filter by producer" do
page.find("#s2id_supplier_id_in").click
find('li', text: supplier2.name).click
tomselect_multiselect supplier2.name, from: 'supplier_id_in[]'
run_report
expect(page.find("table.report__table thead tr")).to have_content(table_header)
@@ -528,8 +518,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
end
it "should filter by fee name" do
page.find(fee_name_selector).click
find('li', text: supplier_fees.name).click
tomselect_multiselect supplier_fees.name, from: 'q[enterprise_fee_id_in][]'
run_report
@@ -557,8 +546,7 @@ RSpec.describe "Enterprise Summary Fee with Tax Report By Producer" do
end
it "should filter by fee owner" do
page.find(fee_owner_selector).click
find('li', text: supplier.name).click
tomselect_multiselect supplier.name, from: 'q[enterprise_fee_owner_id_in][]'
run_report
expect(page.find("table.report__table thead tr")).to have_content(table_header)