diff --git a/apps/block_scout_web/assets/css/app.scss b/apps/block_scout_web/assets/css/app.scss index f855fffa48..32aa84d2df 100644 --- a/apps/block_scout_web/assets/css/app.scss +++ b/apps/block_scout_web/assets/css/app.scss @@ -76,7 +76,7 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts"; @import "components/badge"; @import "components/description-list"; @import "components/nounderline-link"; - +@import "components/token-balance-dropdown"; :export { primary: $primary; diff --git a/apps/block_scout_web/assets/css/components/_token-balance-dropdown.scss b/apps/block_scout_web/assets/css/components/_token-balance-dropdown.scss new file mode 100644 index 0000000000..fd3cde0bcd --- /dev/null +++ b/apps/block_scout_web/assets/css/components/_token-balance-dropdown.scss @@ -0,0 +1,35 @@ +.token-balance-dropdown { + min-width: 14.375rem; + margin-top: 1rem; + background-color: $gray-100; + box-shadow: 0px 2px 3px 2px $gray-200; + border: none; + + // Overriding style added by Bootstrap dropdown via JS. + left: -17px !important; + + .dropdown-items { + overflow-y: auto; + max-height: 18.5rem; + + .dropdown-item:hover { + color: $white; + } + } + + &:after, &:before { + bottom: 100%; + left: 14%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + } + + &:before { + border-bottom-color: $gray-100; + border-width: 1rem; + margin-left: -1rem; + } +} diff --git a/apps/block_scout_web/assets/js/app.js b/apps/block_scout_web/assets/js/app.js index d70012eff8..6ac7d9cebf 100644 --- a/apps/block_scout_web/assets/js/app.js +++ b/apps/block_scout_web/assets/js/app.js @@ -27,6 +27,7 @@ import './lib/tooltip' import './lib/smart_contract/read_only_functions' import './lib/pretty_json' import './lib/try_api' +import './lib/token_balance_dropdown' import './pages/address' import './pages/block' diff --git a/apps/block_scout_web/assets/js/lib/token_balance_dropdown.js b/apps/block_scout_web/assets/js/lib/token_balance_dropdown.js new file mode 100644 index 0000000000..c1e8759859 --- /dev/null +++ b/apps/block_scout_web/assets/js/lib/token_balance_dropdown.js @@ -0,0 +1,19 @@ +import $ from 'jquery' + +const tokenBalanceDropdown = (element) => { + const $element = $(element) + const $loading = $element.find('[data-loading]') + const $errorMessage = $element.find('[data-error-message]') + const apiPath = element.dataset.api_path + + $loading.show() + + $.get(apiPath) + .done(response => $element.html(response)) + .fail(() => { + $loading.hide() + $errorMessage.show() + }) +} + +$('[data-token-balance-dropdown]').each((_index, element) => tokenBalanceDropdown(element)) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex new file mode 100644 index 0000000000..ddadeac55c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex @@ -0,0 +1,24 @@ +defmodule BlockScoutWeb.AddressTokenBalanceController do + use BlockScoutWeb, :controller + + alias Explorer.Chain + alias Explorer.Token.BalanceReader + + def index(conn, %{"address_id" => address_hash_string}) do + with true <- ajax?(conn), + {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string) do + token_balances = + address_hash + |> Chain.fetch_tokens_from_address_hash() + |> BalanceReader.fetch_token_balances_without_error(address_hash_string) + + conn + |> put_status(200) + |> put_layout(false) + |> render("_token_balances.html", tokens: token_balances) + else + _ -> + not_found(conn) + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index a2ff629bae..5db35e6b50 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -95,6 +95,13 @@ defmodule BlockScoutWeb.Router do only: [:index, :show], as: :read_contract ) + + resources( + "/token_balances", + AddressTokenBalanceController, + only: [:index], + as: :token_balance + ) end resources "/tokens", Tokens.TokenController, only: [:show], as: :token do diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/_token_holdings.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/_token_holdings.html.eex new file mode 100644 index 0000000000..4e80ebcb81 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/_token_holdings.html.eex @@ -0,0 +1,20 @@ +
+
+

<%= gettext "Token Holdings" %>

+ + +
+ class="icon-links ml-3 mb-3" + > +

+ + <%= gettext("Fetching tokens...") %> +

+ + +
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex index 4c0997ffe4..218625241f 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex @@ -1,6 +1,6 @@
-
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex new file mode 100644 index 0000000000..c83a069163 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex @@ -0,0 +1,36 @@ +<%= if Enum.any?(@tokens) do %> + +<% end %> +

<%= tokens_count_title(@tokens) %>

+ + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex new file mode 100644 index 0000000000..7b423c7560 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex @@ -0,0 +1,17 @@ + + +<%= for token <- sort_by_name(@tokens) do %> +
+ <%= link( + to: token_path(@conn, :show, :en, token.contract_address_hash), + class: "dropdown-item" + ) do %> +

<%= token_name(token) %>

+

+ <%= format_according_to_decimals(token.balance, token.decimals) %> <%= token.symbol %> +

+ <% end %> +
+<% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex new file mode 100644 index 0000000000..52636774e6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex @@ -0,0 +1,20 @@ +defmodule BlockScoutWeb.AddressTokenBalanceView do + use BlockScoutWeb, :view + + def tokens_count_title(tokens) do + ngettext("%{count} token", "%{count} tokens", Enum.count(tokens)) + end + + def filter_by_type(tokens, type) do + Enum.filter(tokens, &(&1.type == type)) + end + + @doc """ + Sorts the given list of tokens in alphabetically order considering nil values in the bottom of + the list. + """ + def sort_by_name(tokens) do + {unnamed, named} = Enum.split_with(tokens, &is_nil(&1.name)) + Enum.sort_by(named, &String.downcase(&1.name)) ++ unnamed + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs new file mode 100644 index 0000000000..51c4b25b9a --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs @@ -0,0 +1,30 @@ +defmodule BlockScoutWeb.AddressTokenBalanceViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.AddressTokenBalanceView + + describe "sort_by_name/1" do + test "sorts the given tokens by its name" do + token_a = build(:token, name: "token name") + token_b = build(:token, name: "token") + token_c = build(:token, name: "atoken") + + assert AddressTokenBalanceView.sort_by_name([token_a, token_b, token_c]) == [token_c, token_b, token_a] + end + + test "considers nil values in the bottom of the list" do + token_a = build(:token, name: nil) + token_b = build(:token, name: "token name") + token_c = build(:token, name: "token") + + assert AddressTokenBalanceView.sort_by_name([token_a, token_b, token_c]) == [token_c, token_b, token_a] + end + + test "considers capitalization" do + token_a = build(:token, name: "Token") + token_b = build(:token, name: "atoken") + + assert AddressTokenBalanceView.sort_by_name([token_a, token_b]) == [token_b, token_a] + end + end +end