diff --git a/app/components/searchable_dropdown_component.rb b/app/components/searchable_dropdown_component.rb index 1f4d5f9e87..67a785edaf 100644 --- a/app/components/searchable_dropdown_component.rb +++ b/app/components/searchable_dropdown_component.rb @@ -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 diff --git a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml index 14d8969348..50d574d360 100644 --- a/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml +++ b/app/components/searchable_dropdown_component/searchable_dropdown_component.html.haml @@ -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 diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index bea2a2adc0..d8efede625 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -4,6 +4,7 @@ module Admin class ReportsController < Spree::Admin::BaseController include ActiveStorage::SetCurrent include ReportsActions + include Reports::AjaxSearch helper ReportsHelper diff --git a/app/controllers/concerns/reports/ajax_search.rb b/app/controllers/concerns/reports/ajax_search.rb new file mode 100644 index 0000000000..03df572a48 --- /dev/null +++ b/app/controllers/concerns/reports/ajax_search.rb @@ -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 diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb index 1caab98aa9..0fa95863e0 100644 --- a/app/helpers/reports_helper.rb +++ b/app/helpers/reports_helper.rb @@ -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') diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 63833e309f..4695559945 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -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 diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml index 4a3af1ef5d..c3aa10070e 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_order.html.haml @@ -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)) diff --git a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml index 1c19788df6..3eb41c1733 100644 --- a/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml +++ b/app/views/admin/reports/filters/_enterprise_fees_with_tax_report_by_producer.html.haml @@ -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)) diff --git a/app/webpacker/controllers/tom_select_controller.js b/app/webpacker/controllers/tom_select_controller.js index d1d3ade58a..3867fa3380 100644 --- a/app/webpacker/controllers/tom_select_controller.js +++ b/app/webpacker/controllers/tom_select_controller.js @@ -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"; + } } diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b80d340bd4..305ef999f6 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -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 diff --git a/jest.config.js b/jest.config.js index 99c9c31e3b..2e451b9051 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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, diff --git a/lib/reporting/frontend_data.rb b/lib/reporting/frontend_data.rb index 5a1152d4a2..682e071fb1 100644 --- a/lib/reporting/frontend_data.rb +++ b/lib/reporting/frontend_data.rb @@ -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 diff --git a/spec/javascripts/stimulus/tom_select_controller_test.js b/spec/javascripts/stimulus/tom_select_controller_test.js new file mode 100644 index 0000000000..4f4705e222 --- /dev/null +++ b/spec/javascripts/stimulus/tom_select_controller_test.js @@ -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(` + + `); + }); + + 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(` + + `); + }); + + 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(` + + `); + }); + + 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); + }); + }); +}); diff --git a/spec/requests/admin/reports_ajax_api_spec.rb b/spec/requests/admin/reports_ajax_api_spec.rb new file mode 100644 index 0000000000..cb4f50c0c2 --- /dev/null +++ b/spec/requests/admin/reports_ajax_api_spec.rb @@ -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 diff --git a/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb b/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb index 002e0a7247..2218908fac 100644 --- a/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb +++ b/spec/system/admin/reports/enterprise_summary_fees/enterprise_summary_fee_with_tax_report_by_producer_spec.rb @@ -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)