diff --git a/Gemfile b/Gemfile index cdb9fca059f..61bb7c5c1d1 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails" # Hotwire gem "stimulus-rails" gem "turbo-rails" +gem "hotwire_combobox" # Background Jobs gem "good_job" diff --git a/Gemfile.lock b/Gemfile.lock index dc2c8dee305..e1e5558e88f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -188,6 +188,10 @@ GEM actioncable (>= 6.0.0) listen (>= 3.0.0) railties (>= 6.0.0) + hotwire_combobox (0.3.2) + rails (>= 7.0.7.2) + stimulus-rails (>= 1.2) + turbo-rails (>= 1.2) i18n (1.14.6) concurrent-ruby (~> 1.0) i18n-tasks (1.0.14) @@ -485,6 +489,7 @@ DEPENDENCIES good_job holidays hotwire-livereload + hotwire_combobox i18n-tasks image_processing (>= 1.2) importmap-rails diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 337115366dc..f96f0c88ca0 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -19,7 +19,8 @@ @apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100; } - .form-field__label { + + .form-field__label, .hw-combobox__label { @apply block text-xs text-gray-500 peer-disabled:text-gray-400; } @@ -120,6 +121,33 @@ } } +.combobox { + .hw-combobox__main__wrapper, .hw-combobox__input { + @apply w-full; + } + + .hw-combobox__main__wrapper { + @apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none; + } + + .hw-combobox__listbox { + @apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30; + } + + .hw_combobox__pagination__wrapper { + @apply h-px; + + &:only-child { + @apply bg-transparent; + } + } + + --hw-border-color: rgba(0, 0, 0, 0.2); + --hw-handle-width: 20px; + --hw-handle-height: 20px; + --hw-handle-offset-right: 0px; +} + /* Small, single purpose classes that should take precedence over other styles */ @layer utilities { .scrollbar::-webkit-scrollbar { diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index b5b6092ad99..8b5a878c188 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -33,6 +33,10 @@ def update end end + def securities + @pagy, @securities = pagy(Security.order(:name).search(params[:q]), limit: 20) + end + private def set_account diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index 1dfad971d11..4610407f418 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -31,6 +31,7 @@ def create_entry end def security + return Security.find(ticker) if ticker.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i) Security.find_or_create_by(ticker: ticker) end diff --git a/app/models/security.rb b/app/models/security.rb index 196835ad701..94077182d4d 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -6,12 +6,36 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false } + scope :search, ->(query) { + return none if query.blank? || query.length < 2 + + # Clean and normalize the search terms + sanitized_query = query.split.map do |term| + cleaned_term = term.gsub(/[^a-zA-Z0-9]/, " ").strip + next if cleaned_term.blank? + cleaned_term + end.compact.join(" | ") + + return none if sanitized_query.blank? + + sanitized_query = ActiveRecord::Base.connection.quote(sanitized_query) + + where("search_vector @@ to_tsquery('simple', #{sanitized_query}) AND exchange_mic IS NOT NULL") + .select("securities.*, ts_rank_cd(search_vector, to_tsquery('simple', #{sanitized_query})) AS rank") + .reorder("rank DESC") + } + def current_price @current_price ||= Security::Price.find_price(ticker:, date: Date.current) return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end + def to_combobox_display + "#{ticker} - #{name} (#{exchange_acronym})" + end + + private def upcase_ticker diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index e39c7b15d12..283a7725451 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -7,10 +7,12 @@
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
- <%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %> +
+ <%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder"), autocomplete: :list, free_text: true %> +
- <%= form.date_field :date, label: true %> + <%= form.date_field :date, label: true, value: Date.current %>