diff --git a/Gemfile b/Gemfile index e582bfe..acf8b3e 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,6 @@ gem 'quickbooks-ruby' gem 'oauth2' gem 'roxml' gem 'will_paginate' -gem 'rails-jquery-autocomplete' gem 'jquery-ui-rails' gem 'rexml' gem 'combine_pdf' diff --git a/app/controllers/customers_controller.rb b/app/controllers/customers_controller.rb index dbca749..ef850df 100644 --- a/app/controllers/customers_controller.rb +++ b/app/controllers/customers_controller.rb @@ -30,8 +30,6 @@ class CustomersController < ApplicationController before_action :view_customer, except: [:new, :view] skip_before_action :verify_authenticity_token, :check_if_login_required, only: [:view] - autocomplete :customer, :name, full: true, extra_data: [:id] - def address_to_s(address) return if address.nil? @@ -62,6 +60,19 @@ class CustomersController < ApplicationController params.require(:customer).permit(:name, :email, :primary_phone, :mobile_phone, :phone_number, :notes) end + # Used for autocomplete form + def autocomplete + term = ActiveRecord::Base.sanitize_sql_like(params[:q].to_s) + + items = Customer.where("name LIKE :t OR phone_number LIKE :t OR mobile_phone_number LIKE :t", t: "%#{term}%") + .order(:name) + .limit(20) + + render json: items.map { |i| + { id: i.id, name: i.name, phone_number: i.phone_number, mobile_phone_number: i.mobile_phone_number } + } + end + def create @customer = Customer.new(allowed_params) @customer.save diff --git a/app/views/qbo/_settings.html.erb b/app/views/qbo/_settings.html.erb index a63ce90..a9788b5 100644 --- a/app/views/qbo/_settings.html.erb +++ b/app/views/qbo/_settings.html.erb @@ -71,7 +71,7 @@
- <%= link_to t(:label_sync_now_customers), customers_sync_path, class: 'button icon icon-reload' %> + <%= link_to t(:label_sync_now_customers), sync_customers_path, class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_employees), employees_sync_path, class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_invoices), invoices_sync_path, class: 'button icon icon-reload' %> <%= link_to t(:label_sync_now_estimate), estimates_sync_path, class: 'button icon icon-reload' %> diff --git a/assets/javascripts/autocomplete.js b/assets/javascripts/autocomplete.js new file mode 100644 index 0000000..ff70373 --- /dev/null +++ b/assets/javascripts/autocomplete.js @@ -0,0 +1,102 @@ +(function () { + + // Helper: escape HTML for safety + function escapeHtml(str) { + return $("
").text(str).html(); + } + + // Helper: highlight all occurrences of term (case-insensitive) + function highlightTerm(text, term) { + if (!term) return text; + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp("(" + escapedTerm + ")", "ig"); + return text.replace(regex, "$1"); + } + + window.initCustomerAutocomplete = function(context) { + let scope = context || document; + + $(scope).find(".customer-name").each(function() { + if ($(this).data("autocomplete-initialized")) return; + $(this).data("autocomplete-initialized", true); + + let $input = $(this); + + let ac = $input.autocomplete({ + appendTo: "body", // crucial for Redmine positioning + minLength: 2, + + source: function(request, response) { + $.getJSON("/customers/autocomplete", { q: request.term }) + .done(function(data) { + response(data.map(function(item) { + // combine secondary info + let secondary = []; + if (item.phone_number) secondary.push(item.phone_number); + if (item.mobile_phone_number) secondary.push(item.mobile_phone_number); + + let meta = secondary.length ? " (" + secondary.join(" • ") + ")" : ""; + + // escape HTML to avoid XSS + let safeText = escapeHtml(item.name + meta); + + return { + label: item.name + meta, // plain fallback + value: item.name, // goes into input + id: item.id, + html: highlightTerm(safeText, request.term) + }; + })); + }) + .fail(function() { + response([]); + }); + }, + + select: function(event, ui) { + $input.val(ui.item.value); // visible text + $("#issue_customer_id").val(ui.item.id); // hidden ID + + // trigger Redmine form update safely + setTimeout(function() { + $("#issue_customer_id").trigger("change"); + }, 0); + + return false; + }, + + change: function(event, ui) { + // clear hidden field if no valid selection + if (!ui.item && !$input.val()) { + $("#issue_customer_id").val(""); + } + } + }); + + // Render item HTML for highlight + ac.autocomplete("instance")._renderItem = function(ul, item) { + return $("
  • ") + .append($("
    ").html(item.html)) + .appendTo(ul); + }; + }); + }; + + // Re-init after Redmine AJAX form updates + $(document).on("ajaxComplete", function() { + if (window.initCustomerAutocomplete) { + window.initCustomerAutocomplete(document); + } + }); + + // Init on page load + $(document).ready(function() { + window.initCustomerAutocomplete(document); + }); + + // Also init on Turbo/Redmine load events + document.addEventListener("turbo:load", function() { + window.initCustomerAutocomplete(document); + }); + +})(); \ No newline at end of file diff --git a/assets/stylesheets/autocomplete.css b/assets/stylesheets/autocomplete.css new file mode 100644 index 0000000..8530ce9 --- /dev/null +++ b/assets/stylesheets/autocomplete.css @@ -0,0 +1,5 @@ +/* Keep Redmine default look, just enhance metadata */ +.ui-autocomplete .autocomplete-meta { + color: #888; + font-size: 0.9em; +} \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4b3f757..b6a7441 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,7 +14,6 @@ get 'qbo/oauth_callback', to: 'qbo#oauth_callback' #manual sync get 'qbo/sync', to: 'qbo#sync' -get 'customers/sync', to: 'customers#sync' get 'invoices/sync', to: 'invoices#sync' get 'estimates/sync', to: 'estimates#sync' get 'employees/sync', to: 'employees#sync' @@ -39,5 +38,8 @@ get 'filter_estimates_by_customer' => 'customers#filter_estimates_by_customer' get 'filter_invoices_by_customer' => 'customers#filter_invoices_by_customer' resources :customers do - get :autocomplete_customer_name, on: :collection + collection do + get :autocomplete + get :sync + end end \ No newline at end of file diff --git a/lib/redmine_qbo/hooks/issues_hook_listener.rb b/lib/redmine_qbo/hooks/issues_hook_listener.rb index 4e47847..7f5d7c6 100644 --- a/lib/redmine_qbo/hooks/issues_hook_listener.rb +++ b/lib/redmine_qbo/hooks/issues_hook_listener.rb @@ -23,13 +23,13 @@ module RedmineQbo project = context[:project] # Customer Name Text Box with database backed autocomplete - # onchange event will update the hidden customer_id field - search_customer = f.autocomplete_field :customer, - autocomplete_customer_name_customers_path, - selected: issue.customer, - update_elements: { - id: '#issue_customer_id', - value: '#issue_customer' + # onchange event will update the hidden customer_id field + search_customer = f.text_field :customer, + placeholder: l(:field_customer), + class: "customer-name", + autocomplete: "off", + data: { + autocomplete_url: "/customers/autocomplete" } # We need to handle 3 cases for the onchange event of the customer name field: diff --git a/lib/redmine_qbo/hooks/view_hook_listener.rb b/lib/redmine_qbo/hooks/view_hook_listener.rb index 4a5c944..da6862a 100644 --- a/lib/redmine_qbo/hooks/view_hook_listener.rb +++ b/lib/redmine_qbo/hooks/view_hook_listener.rb @@ -17,8 +17,9 @@ module RedmineQbo def view_layouts_base_html_head(context = {}) safe_join([ javascript_include_tag( 'application.js', plugin: :redmine_qbo), - javascript_include_tag( 'autocomplete-rails.js', plugin: :redmine_qbo), - javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo) + javascript_include_tag( 'autocomplete.js', plugin: :redmine_qbo), + javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo), + stylesheet_link_tag( 'autocomplete', plugin: :redmine_qbo) ]) end