Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -485,6 +489,7 @@ DEPENDENCIES
good_job
holidays
hotwire-livereload
hotwire_combobox
i18n-tasks
image_processing (>= 1.2)
importmap-rails
Expand Down
30 changes: 29 additions & 1 deletion app/assets/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/account/trades_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/account/trade_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +34 to 35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Shpigford @zachgoll you might want to have some additional processing here.

Because you're returning search results on company names (in addition to ticker symbols), and also allowing free-text entry, people might enter e.g. "Apple". They'll see that the combobox returned an equivalent result, and blur the combobox without selecting anything.

At that point the combobox will think they meant to enter the literal string "Apple", and not "AAPL". I think that'll result in a Security with ticker "Apple" being created globally. But I haven't taken the time to really understand this builder class yet.

Might not be a problem in practice, just wanted to point that out.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tricky one because for self-hosters who won't have the securities loaded yet, we'd still need to support the free-text entry.

That said, I think @Shpigford is working on a slightly different data model for securities + prices right now that may make it so we can be more strict with this input—i.e. only allowing IDs to be passed to the input's value and throwing a required client-side validation if not selected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That new data model solution sounds good!

If and when you switch to that, changing the combobox to autocomplete: :both (the default) instead of :list will always select the best match, too. So it'll help with the required UX.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josefarias awesome, will do. Thanks for the heads up!

end

Expand Down
24 changes: 24 additions & 0 deletions app/models/security.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/views/account/trades/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
<div class="space-y-2">
<%= 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" } } %>
<div data-trade-form-target="tickerInput">
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
<div class="form-field combobox">
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder"), autocomplete: :list, free_text: true %>
</div>
</div>

<%= form.date_field :date, label: true %>
<%= form.date_field :date, label: true, value: Date.current %>

<div data-trade-form-target="amountInput" hidden>
<%= form.money_field :amount, label: t(".amount"), disable_currency: true %>
Expand Down
7 changes: 7 additions & 0 deletions app/views/account/trades/_tickers.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="flex items-center">
<%= image_tag("https://logo.synthfinance.com/ticker/#{tickers&.ticker}", class: "rounded-full h-8 w-8 inline-block mr-2") %>
<div class="flex flex-col">
<span class="text-sm font-medium"><%= tickers&.name.presence || tickers&.ticker %></span>
<span class="text-xs text-gray-500"><%= "#{tickers&.ticker} (#{tickers&.exchange_acronym})" %></span>
</div>
</div>
3 changes: 3 additions & 0 deletions app/views/account/trades/securities.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= async_combobox_options @securities,
render_in: { partial: "account/trades/tickers" },
next_page: @pagy.next %>
2 changes: 2 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

<%= combobox_style_tag %>

<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Expand Down
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@

resources :transactions, only: %i[index update]
resources :valuations, only: %i[index new create]
resources :trades, only: %i[index new create update]
resources :trades, only: %i[index new create update] do
get :securities, on: :collection
end

resources :entries, only: %i[edit update show destroy]
end
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20241025182612_add_search_vector_to_securities.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddSearchVectorToSecurities < ActiveRecord::Migration[7.2]
def change
add_column :securities, :search_vector, :virtual, type: :tsvector, as: "setweight(to_tsvector('simple', coalesce(ticker, '')), 'B') || to_tsvector('simple', coalesce(name, ''))", stored: true
add_index :securities, :search_vector, using: :gin
end
end
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/fixtures/securities.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
aapl:
ticker: AAPL
name: Apple
exchange_mic: XNAS

msft:
ticker: MSFT
name: Microsoft
exchange_mic: XNAS
12 changes: 10 additions & 2 deletions test/system/trades_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ class TradesTest < ApplicationSystemTestCase

open_new_trade_modal

fill_in "Ticker symbol", with: "NVDA"
fill_in "Ticker symbol", with: "AAPL"
select_combobox_option("Apple")
fill_in "Date", with: Date.current
fill_in "Quantity", with: shares_qty
fill_in "account_entry[price]", with: 214.23
Expand All @@ -27,7 +28,7 @@ class TradesTest < ApplicationSystemTestCase

within_trades do
assert_text "Purchase 10 shares of AAPL"
assert_text "Buy #{shares_qty} shares of NVDA"
assert_text "Buy #{shares_qty} shares of AAPL"
end
end

Expand All @@ -38,6 +39,7 @@ class TradesTest < ApplicationSystemTestCase

select "Sell", from: "Type"
fill_in "Ticker symbol", with: aapl.ticker
select_combobox_option(aapl.security.name)
fill_in "Date", with: Date.current
fill_in "Quantity", with: aapl.qty
fill_in "account_entry[price]", with: 215.33
Expand All @@ -64,4 +66,10 @@ def within_trades(&block)
def visit_account_trades
visit account_url(@account, tab: "transactions")
end

def select_combobox_option(text)
within "#account_entry_ticker-hw-listbox" do
find("li", text: text).click
end
end
end