diff --git a/.storybook/test-data.js b/.storybook/test-data.js index de94b69f857e..cbcebb6347ed 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -677,6 +677,11 @@ const state = { currentLocale: 'en', preferences: { showNativeTokenAsMainBalance: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, }, incomingTransactionsPreferences: { [CHAIN_IDS.MAINNET]: true, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 30c913d1de74..60ec9579059d 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5245,6 +5245,16 @@ "somethingWentWrong": { "message": "Oops! Something went wrong." }, + "sortBy": { + "message": "Sort by" + }, + "sortByAlphabetically": { + "message": "Alphabetically (A-Z)" + }, + "sortByDecliningBalance": { + "message": "Declining balance ($1 high-low)", + "description": "Indicates a descending order based on token fiat balance. $1 is the preferred currency symbol" + }, "source": { "message": "Source" }, diff --git a/app/scripts/controllers/metametrics.js b/app/scripts/controllers/metametrics.js index ef1dbe02789a..38872421c1f6 100644 --- a/app/scripts/controllers/metametrics.js +++ b/app/scripts/controllers/metametrics.js @@ -862,6 +862,8 @@ export default class MetaMetricsController { metamaskState.participateInMetaMetrics, [MetaMetricsUserTrait.HasMarketingConsent]: metamaskState.dataCollectionForMarketing, + [MetaMetricsUserTrait.TokenSortPreference]: + metamaskState.tokenSortConfig?.key || '', }; if (!previousUserTraits) { diff --git a/app/scripts/controllers/metametrics.test.js b/app/scripts/controllers/metametrics.test.js index 3d4845e056d0..a0505700ef01 100644 --- a/app/scripts/controllers/metametrics.test.js +++ b/app/scripts/controllers/metametrics.test.js @@ -1122,6 +1122,11 @@ describe('MetaMetricsController', function () { }, }, }, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, }); expect(traits).toStrictEqual({ @@ -1153,6 +1158,7 @@ describe('MetaMetricsController', function () { ///: BEGIN:ONLY_INCLUDE_IF(petnames) [MetaMetricsUserTrait.PetnameAddressCount]: 3, ///: END:ONLY_INCLUDE_IF + [MetaMetricsUserTrait.TokenSortPreference]: 'token-sort-key', }); }); @@ -1181,6 +1187,11 @@ describe('MetaMetricsController', function () { useNftDetection: false, theme: 'default', useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, showNativeTokenAsMainBalance: true, }); @@ -1208,6 +1219,11 @@ describe('MetaMetricsController', function () { useNftDetection: false, theme: 'default', useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, showNativeTokenAsMainBalance: false, }); @@ -1245,6 +1261,11 @@ describe('MetaMetricsController', function () { useNftDetection: true, theme: 'default', useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, showNativeTokenAsMainBalance: true, }); @@ -1267,6 +1288,11 @@ describe('MetaMetricsController', function () { useNftDetection: true, theme: 'default', useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, showNativeTokenAsMainBalance: true, }); expect(updatedTraits).toStrictEqual(null); diff --git a/app/scripts/controllers/preferences-controller.ts b/app/scripts/controllers/preferences-controller.ts index a158ac0024d4..eb126b176a41 100644 --- a/app/scripts/controllers/preferences-controller.ts +++ b/app/scripts/controllers/preferences-controller.ts @@ -106,6 +106,11 @@ export type Preferences = { showMultiRpcModal: boolean; isRedesignedConfirmationsDeveloperEnabled: boolean; showConfirmationAdvancedDetails: boolean; + tokenSortConfig: { + key: string; + order: string; + sortCallback: string; + }; shouldShowAggregatedBalancePopover: boolean; }; @@ -237,6 +242,11 @@ export default class PreferencesController { showMultiRpcModal: false, isRedesignedConfirmationsDeveloperEnabled: false, showConfirmationAdvancedDetails: false, + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, shouldShowAggregatedBalancePopover: true, // by default user should see popover; }, // ENS decentralized website resolution diff --git a/app/scripts/migrations/130.test.ts b/app/scripts/migrations/130.test.ts new file mode 100644 index 000000000000..94e00949c7a1 --- /dev/null +++ b/app/scripts/migrations/130.test.ts @@ -0,0 +1,91 @@ +import { migrate, version } from './130'; + +const oldVersion = 129; + +describe(`migration #${version}`, () => { + it('updates the version metadata', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: {}, + }; + const newStorage = await migrate(oldStorage); + expect(newStorage.meta).toStrictEqual({ version }); + }); + describe(`migration #${version}`, () => { + it('updates the preferences with a default tokenSortConfig', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: { + preferences: {}, + }, + }, + }; + const expectedData = { + PreferencesController: { + preferences: { + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + }, + }, + }; + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(expectedData); + }); + + it('does nothing if the preferences already has a tokenSortConfig', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: { + preferences: { + tokenSortConfig: { + key: 'fooKey', + order: 'foo', + sortCallback: 'fooCallback', + }, + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(oldStorage.data); + }); + + it('does nothing to other preferences if they exist without a tokenSortConfig', async () => { + const oldStorage = { + meta: { version: oldVersion }, + data: { + PreferencesController: { + preferences: { + existingPreference: true, + }, + }, + }, + }; + + const expectedData = { + PreferencesController: { + preferences: { + existingPreference: true, + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + }, + }, + }; + + const newStorage = await migrate(oldStorage); + + expect(newStorage.data).toStrictEqual(expectedData); + }); + }); +}); diff --git a/app/scripts/migrations/130.ts b/app/scripts/migrations/130.ts new file mode 100644 index 000000000000..ccf376ce1e7e --- /dev/null +++ b/app/scripts/migrations/130.ts @@ -0,0 +1,44 @@ +import { hasProperty, isObject } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +type VersionedData = { + meta: { version: number }; + data: Record; +}; +export const version = 130; +/** + * This migration adds a tokenSortConfig to the user's preferences + * + * + * @param originalVersionedData - Versioned MetaMask extension state, exactly what we persist to dist. + * @param originalVersionedData.meta - State metadata. + * @param originalVersionedData.meta.version - The current state version. + * @param originalVersionedData.data - The persisted MetaMask state, keyed by controller. + * @returns Updated versioned MetaMask extension state. + */ +export async function migrate( + originalVersionedData: VersionedData, +): Promise { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + transformState(versionedData.data); + return versionedData; +} +function transformState( + state: Record, +): Record { + if ( + hasProperty(state, 'PreferencesController') && + isObject(state.PreferencesController) && + hasProperty(state.PreferencesController, 'preferences') && + isObject(state.PreferencesController.preferences) && + !state.PreferencesController.preferences.tokenSortConfig + ) { + state.PreferencesController.preferences.tokenSortConfig = { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }; + } + return state; +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 296ff8077613..93a862b5ee02 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -149,6 +149,7 @@ const migrations = [ require('./127'), require('./128'), require('./129'), + require('./130'), ]; export default migrations; diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index d0f1cfb87cbe..8faf7c7bfb79 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -472,6 +472,10 @@ export enum MetaMetricsUserTrait { * Identified when the user selects a currency from settings */ CurrentCurrency = 'current_currency', + /** + * Identified when the user changes token sort order on asset-list + */ + TokenSortPreference = 'token_sort_preference', } /** @@ -630,6 +634,7 @@ export enum MetaMetricsEventName { TokenScreenOpened = 'Token Screen Opened', TokenAdded = 'Token Added', TokenRemoved = 'Token Removed', + TokenSortPreference = 'Token Sort Preference', NFTRemoved = 'NFT Removed', TokenDetected = 'Token Detected', TokenHidden = 'Token Hidden', diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 32a61c573500..654e915a1305 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -372,7 +372,12 @@ "showFiatInTestnets": false, "showNativeTokenAsMainBalance": true, "showTestNetworks": true, - "smartTransactionsOptInStatus": false + "smartTransactionsOptInStatus": false, + "tokenSortConfig": { + "key": "tokenFiatAmount", + "order": "dsc", + "sortCallback": "stringNumeric" + } }, "ensResolutionsByAddress": {}, "isAccountMenuOpen": false, diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index 83b8b29a5e83..2c0dfe9a23cb 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -215,6 +215,11 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { showMultiRpcModal: false, isRedesignedConfirmationsDeveloperEnabled: false, showConfirmationAdvancedDetails: false, + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, shouldShowAggregatedBalancePopover: true, }, selectedAddress: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index 415af23071e7..f1e9a7e5ae1d 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -77,6 +77,11 @@ function onboardingFixture() { showMultiRpcModal: false, isRedesignedConfirmationsDeveloperEnabled: false, showConfirmationAdvancedDetails: false, + tokenSortConfig: { + key: 'tokenFiatAmount', + order: 'dsc', + sortCallback: 'stringNumeric', + }, shouldShowAggregatedBalancePopover: true, }, useExternalServices: true, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index 8d8c8c1ae895..559e8a256d43 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -215,7 +215,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", - "showMultiRpcModal": "boolean", + "tokenSortConfig": "object", "shouldShowAggregatedBalancePopover": "boolean" }, "ipfsGateway": "string", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index b1131ec4e7a2..2df9ee4e2f23 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -37,6 +37,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", + "tokenSortConfig": "object", "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index f40b2687316b..d22b69967027 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -80,11 +80,11 @@ }, "NetworkController": { "selectedNetworkClientId": "string", + "networkConfigurations": "object", "networksMetadata": { "networkConfigurationId": { "EIPS": {}, "status": "available" } }, - "providerConfig": "object", - "networkConfigurations": "object" + "providerConfig": "object" }, "OnboardingController": { "completedOnboarding": true, @@ -114,8 +114,9 @@ "smartTransactionsOptInStatus": false, "showNativeTokenAsMainBalance": true, "petnamesEnabled": true, - "showConfirmationAdvancedDetails": false, "isRedesignedConfirmationsDeveloperEnabled": "boolean", + "showConfirmationAdvancedDetails": false, + "tokenSortConfig": "object", "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 3c692fa59405..2dfd6ac6ef21 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -80,11 +80,11 @@ }, "NetworkController": { "selectedNetworkClientId": "string", + "networkConfigurations": "object", "networksMetadata": { "networkConfigurationId": { "EIPS": {}, "status": "available" } }, - "providerConfig": "object", - "networkConfigurations": "object" + "providerConfig": "object" }, "OnboardingController": { "completedOnboarding": true, @@ -114,8 +114,9 @@ "smartTransactionsOptInStatus": false, "showNativeTokenAsMainBalance": true, "petnamesEnabled": true, - "showConfirmationAdvancedDetails": false, "isRedesignedConfirmationsDeveloperEnabled": "boolean", + "showConfirmationAdvancedDetails": false, + "tokenSortConfig": "object", "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/tokens/add-hide-token.spec.js b/test/e2e/tests/tokens/add-hide-token.spec.js index a13ef9caa2b5..535948ba1c9b 100644 --- a/test/e2e/tests/tokens/add-hide-token.spec.js +++ b/test/e2e/tests/tokens/add-hide-token.spec.js @@ -119,7 +119,7 @@ describe('Add existing token using search', function () { async ({ driver }) => { await unlockWallet(driver); - await driver.clickElement({ text: 'Import tokens', tag: 'button' }); + await driver.clickElement({ text: 'Import', tag: 'button' }); await driver.fill('input[placeholder="Search tokens"]', 'BAT'); await driver.clickElement({ text: 'BAT', diff --git a/test/e2e/tests/tokens/custom-token-add-approve.spec.js b/test/e2e/tests/tokens/custom-token-add-approve.spec.js index a9cf1829a808..7a59243da403 100644 --- a/test/e2e/tests/tokens/custom-token-add-approve.spec.js +++ b/test/e2e/tests/tokens/custom-token-add-approve.spec.js @@ -35,7 +35,7 @@ describe('Create token, approve token and approve token without gas', function ( ); await clickNestedButton(driver, 'Tokens'); - await driver.clickElement({ text: 'Import tokens', tag: 'button' }); + await driver.clickElement({ text: 'Import', tag: 'button' }); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/custom-token-send-transfer.spec.js b/test/e2e/tests/tokens/custom-token-send-transfer.spec.js index de2aa2addcf8..40b1872011bd 100644 --- a/test/e2e/tests/tokens/custom-token-send-transfer.spec.js +++ b/test/e2e/tests/tokens/custom-token-send-transfer.spec.js @@ -136,6 +136,12 @@ describe('Transfer custom tokens @no-mmi', function () { text: '-1.5 TST', }); + // this selector helps prevent flakiness. it allows driver to wait until send transfer is "confirmed" + await driver.waitForSelector({ + text: 'Confirmed', + tag: 'div', + }); + // check token amount is correct after transaction await clickNestedButton(driver, 'Tokens'); const tokenAmount = await driver.findElement( @@ -192,6 +198,12 @@ describe('Transfer custom tokens @no-mmi', function () { text: 'Send TST', }); + // this selector helps prevent flakiness. it allows driver to wait until send transfer is "confirmed" + await driver.waitForSelector({ + text: 'Confirmed', + tag: 'div', + }); + // check token amount is correct after transaction await clickNestedButton(driver, 'Tokens'); const tokenAmount = await driver.findElement( diff --git a/test/e2e/tests/tokens/token-details.spec.ts b/test/e2e/tests/tokens/token-details.spec.ts index 349c273c721c..0d577ab20f19 100644 --- a/test/e2e/tests/tokens/token-details.spec.ts +++ b/test/e2e/tests/tokens/token-details.spec.ts @@ -27,7 +27,7 @@ describe('Token Details', function () { }; const importToken = async (driver: Driver) => { - await driver.clickElement({ text: 'Import tokens', tag: 'button' }); + await driver.clickElement({ text: 'Import', tag: 'button' }); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-list.spec.ts b/test/e2e/tests/tokens/token-list.spec.ts index 32b5ea85e3ae..bffef04c40dd 100644 --- a/test/e2e/tests/tokens/token-list.spec.ts +++ b/test/e2e/tests/tokens/token-list.spec.ts @@ -27,7 +27,7 @@ describe('Token List', function () { }; const importToken = async (driver: Driver) => { - await driver.clickElement({ text: 'Import tokens', tag: 'button' }); + await driver.clickElement({ text: 'Import', tag: 'button' }); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-sort.spec.ts b/test/e2e/tests/tokens/token-sort.spec.ts new file mode 100644 index 000000000000..e0d335ee0fd6 --- /dev/null +++ b/test/e2e/tests/tokens/token-sort.spec.ts @@ -0,0 +1,111 @@ +import { strict as assert } from 'assert'; +import { Context } from 'mocha'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; +import FixtureBuilder from '../../fixture-builder'; +import { + clickNestedButton, + defaultGanacheOptions, + regularDelayMs, + unlockWallet, + withFixtures, +} from '../../helpers'; +import { Driver } from '../../webdriver/driver'; + +describe('Token List', function () { + const chainId = CHAIN_IDS.MAINNET; + const tokenAddress = '0x2EFA2Cb29C2341d8E5Ba7D3262C9e9d6f1Bf3711'; + const symbol = 'ABC'; + + const fixtures = { + fixtures: new FixtureBuilder({ inputChainId: chainId }).build(), + ganacheOptions: { + ...defaultGanacheOptions, + chainId: parseInt(chainId, 16), + }, + }; + + const importToken = async (driver: Driver) => { + await driver.clickElement({ text: 'Import', tag: 'button' }); + await clickNestedButton(driver, 'Custom token'); + await driver.fill( + '[data-testid="import-tokens-modal-custom-address"]', + tokenAddress, + ); + await driver.waitForSelector('p.mm-box--color-error-default'); + await driver.fill( + '[data-testid="import-tokens-modal-custom-symbol"]', + symbol, + ); + await driver.delay(2000); + await driver.clickElement({ text: 'Next', tag: 'button' }); + await driver.clickElement( + '[data-testid="import-tokens-modal-import-button"]', + ); + await driver.findElement({ text: 'Token imported', tag: 'h6' }); + }; + + it('should sort alphabetically and by decreasing balance', async function () { + await withFixtures( + { + ...fixtures, + title: (this as Context).test?.fullTitle(), + }, + async ({ driver }: { driver: Driver }) => { + await unlockWallet(driver); + await importToken(driver); + + const tokenListBeforeSorting = await driver.findElements( + '[data-testid="multichain-token-list-button"]', + ); + const tokenSymbolsBeforeSorting = await Promise.all( + tokenListBeforeSorting.map(async (tokenElement) => { + return tokenElement.getText(); + }), + ); + + assert.ok(tokenSymbolsBeforeSorting[0].includes('Ethereum')); + + await await driver.clickElement( + '[data-testid="sort-by-popover-toggle"]', + ); + await await driver.clickElement('[data-testid="sortByAlphabetically"]'); + + await driver.delay(regularDelayMs); + const tokenListAfterSortingAlphabetically = await driver.findElements( + '[data-testid="multichain-token-list-button"]', + ); + const tokenListSymbolsAfterSortingAlphabetically = await Promise.all( + tokenListAfterSortingAlphabetically.map(async (tokenElement) => { + return tokenElement.getText(); + }), + ); + + assert.ok( + tokenListSymbolsAfterSortingAlphabetically[0].includes('ABC'), + ); + + await await driver.clickElement( + '[data-testid="sort-by-popover-toggle"]', + ); + await await driver.clickElement( + '[data-testid="sortByDecliningBalance"]', + ); + + await driver.delay(regularDelayMs); + const tokenListBeforeSortingByDecliningBalance = + await driver.findElements( + '[data-testid="multichain-token-list-button"]', + ); + + const tokenListAfterSortingByDecliningBalance = await Promise.all( + tokenListBeforeSortingByDecliningBalance.map(async (tokenElement) => { + return tokenElement.getText(); + }), + ); + assert.ok( + tokenListAfterSortingByDecliningBalance[0].includes('Ethereum'), + ); + }, + ); + }); +}); diff --git a/test/e2e/tests/transaction/change-assets.spec.js b/test/e2e/tests/transaction/change-assets.spec.js index 11bb7489a829..7ce971fd8d80 100644 --- a/test/e2e/tests/transaction/change-assets.spec.js +++ b/test/e2e/tests/transaction/change-assets.spec.js @@ -342,7 +342,7 @@ describe('Change assets', function () { // Make sure gas is updated by resetting amount and hex data // Note: this is needed until the race condition is fixed on the wallet level (issue #25243) - await driver.fill('[data-testid="currency-input"]', '2'); + await driver.fill('[data-testid="currency-input"]', '2.000042'); await hexDataLocator.fill('0x'); await hexDataLocator.fill(''); diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index a9f65cad0714..900c49731594 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -53,6 +53,8 @@ @import 'srp-input/srp-input'; @import 'snaps/snap-privacy-warning/index'; @import 'tab-bar/index'; +@import 'assets/asset-list/asset-list-control-bar/index'; +@import 'assets/asset-list/sort-control/index'; @import 'assets/token-cell/token-cell'; @import 'assets/token-list-display/token-list-display'; @import 'transaction-activity-log/index'; diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx new file mode 100644 index 000000000000..696c3ca7c89f --- /dev/null +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx @@ -0,0 +1,99 @@ +import React, { useRef, useState } from 'react'; +import { + Box, + ButtonBase, + ButtonBaseSize, + IconName, + Popover, + PopoverPosition, +} from '../../../../component-library'; +import SortControl from '../sort-control'; +import { + BackgroundColor, + BorderColor, + BorderStyle, + Display, + JustifyContent, + TextColor, +} from '../../../../../helpers/constants/design-system'; +import ImportControl from '../import-control'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { getEnvironmentType } from '../../../../../../app/scripts/lib/util'; +import { + ENVIRONMENT_TYPE_NOTIFICATION, + ENVIRONMENT_TYPE_POPUP, +} from '../../../../../../shared/constants/app'; + +type AssetListControlBarProps = { + showTokensLinks?: boolean; +}; + +const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { + const t = useI18nContext(); + const controlBarRef = useRef(null); // Create a ref + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const windowType = getEnvironmentType(); + const isFullScreen = + windowType !== ENVIRONMENT_TYPE_NOTIFICATION && + windowType !== ENVIRONMENT_TYPE_POPUP; + + const handleOpenPopover = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + }; + + return ( + + + {t('sortBy')} + + + + + + + ); +}; + +export default AssetListControlBar; diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss new file mode 100644 index 000000000000..3ed7ae082766 --- /dev/null +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss @@ -0,0 +1,8 @@ +.asset-list-control-bar { + padding-top: 8px; + padding-bottom: 8px; + + &__button:hover { + background-color: var(--color-background-hover); + } +} diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.ts b/ui/components/app/assets/asset-list/asset-list-control-bar/index.ts new file mode 100644 index 000000000000..c9eff91c6fcf --- /dev/null +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.ts @@ -0,0 +1 @@ +export { default } from './asset-list-control-bar'; diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index a84ec99037f9..5cfeb6803875 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -1,22 +1,15 @@ import React, { useContext, useState } from 'react'; import { useSelector } from 'react-redux'; import TokenList from '../token-list'; -import { PRIMARY, SECONDARY } from '../../../../helpers/constants/common'; +import { PRIMARY } from '../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; import { getDetectedTokensInCurrentNetwork, getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, - getShouldHideZeroBalanceTokens, getSelectedAccount, - getPreferences, } from '../../../../selectors'; import { - getMultichainCurrentNetwork, - getMultichainNativeCurrency, getMultichainIsEvm, - getMultichainShouldShowFiat, - getMultichainCurrencyImage, - getMultichainIsMainnet, getMultichainSelectedAccountCachedBalance, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getMultichainIsBitcoin, @@ -32,14 +25,10 @@ import { import DetectedToken from '../../detected-token/detected-token'; import { DetectedTokensBanner, - TokenListItem, ImportTokenLink, ReceiveModal, } from '../../../multichain'; -import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; -import { useIsOriginalNativeTokenSymbol } from '../../../../hooks/useIsOriginalNativeTokenSymbol'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { roundToDecimalPlacesRemovingExtraZeroes } from '../../../../helpers/utils/util'; import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { @@ -48,42 +37,30 @@ import { } from '../../../multichain/ramps-card/ramps-card'; import { getIsNativeTokenBuyable } from '../../../../ducks/ramps'; ///: END:ONLY_INCLUDE_IF +import AssetListControlBar from './asset-list-control-bar'; +import NativeToken from './native-token'; export type TokenWithBalance = { address: string; symbol: string; - string: string; + string?: string; image: string; + secondary?: string; + tokenFiatAmount?: string; + isNative?: boolean; }; -type AssetListProps = { +export type AssetListProps = { onClickAsset: (arg: string) => void; - showTokensLinks: boolean; + showTokensLinks?: boolean; }; const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { const [showDetectedTokens, setShowDetectedTokens] = useState(false); - const nativeCurrency = useSelector(getMultichainNativeCurrency); - const showFiat = useSelector(getMultichainShouldShowFiat); - const isMainnet = useSelector(getMultichainIsMainnet); - const { showNativeTokenAsMainBalance } = useSelector(getPreferences); - const { chainId, ticker, type, rpcUrl } = useSelector( - getMultichainCurrentNetwork, - ); - const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( - chainId, - ticker, - type, - rpcUrl, - ); + const selectedAccount = useSelector(getSelectedAccount); const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); const balance = useSelector(getMultichainSelectedAccountCachedBalance); - const balanceIsLoading = !balance; - const selectedAccount = useSelector(getSelectedAccount); - const shouldHideZeroBalanceTokens = useSelector( - getShouldHideZeroBalanceTokens, - ); const { currency: primaryCurrency, @@ -92,27 +69,12 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ethNumberOfDecimals: 4, shouldCheckShowNativeToken: true, }); - const { - currency: secondaryCurrency, - numberOfDecimals: secondaryNumberOfDecimals, - } = useUserPreferencedCurrency(SECONDARY, { - ethNumberOfDecimals: 4, - shouldCheckShowNativeToken: true, - }); - const [primaryCurrencyDisplay, primaryCurrencyProperties] = - useCurrencyDisplay(balance, { - numberOfDecimals: primaryNumberOfDecimals, - currency: primaryCurrency, - }); - - const [secondaryCurrencyDisplay, secondaryCurrencyProperties] = - useCurrencyDisplay(balance, { - numberOfDecimals: secondaryNumberOfDecimals, - currency: secondaryCurrency, - }); + const [, primaryCurrencyProperties] = useCurrencyDisplay(balance, { + numberOfDecimals: primaryNumberOfDecimals, + currency: primaryCurrency, + }); - const primaryTokenImage = useSelector(getMultichainCurrencyImage); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork) || []; const isTokenDetectionInactiveOnNonMainnetSupportedNetwork = useSelector( getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, @@ -126,23 +88,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { setShowReceiveModal(true); }; - const accountTotalFiatBalance = useAccountTotalFiatBalance( - selectedAccount, - shouldHideZeroBalanceTokens, - ); - - const tokensWithBalances = - accountTotalFiatBalance.tokensWithBalances as TokenWithBalance[]; - - const { loading } = accountTotalFiatBalance; - - tokensWithBalances.forEach((token) => { - token.string = roundToDecimalPlacesRemovingExtraZeroes( - token.string, - 5, - ) as string; - }); - const balanceIsZero = useSelector( getMultichainSelectedAccountCachedBalanceIsZero, ); @@ -150,6 +95,7 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const isBuyableChain = useSelector(getIsNativeTokenBuyable); const shouldShowBuy = isBuyableChain && balanceIsZero; + const isBtc = useSelector(getMultichainIsBitcoin); ///: END:ONLY_INCLUDE_IF const isEvm = useSelector(getMultichainIsEvm); @@ -157,15 +103,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { // for EVM assets const shouldShowTokensLinks = showTokensLinks ?? isEvm; - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - const isBtc = useSelector(getMultichainIsBitcoin); - ///: END:ONLY_INCLUDE_IF - - let isStakeable = isMainnet && isEvm; - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - isStakeable = false; - ///: END:ONLY_INCLUDE_IF - return ( <> {detectedTokens.length > 0 && @@ -176,6 +113,21 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { margin={4} /> )} + + } + onTokenClick={(tokenAddress: string) => { + onClickAsset(tokenAddress); + trackEvent({ + event: MetaMetricsEventName.TokenScreenOpened, + category: MetaMetricsEventCategory.Navigation, + properties: { + token_symbol: primaryCurrencyProperties.suffix, + location: 'Home', + }, + }); + }} + /> { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) shouldShowBuy ? ( @@ -192,43 +144,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ) : null ///: END:ONLY_INCLUDE_IF } - onClickAsset(nativeCurrency)} - title={nativeCurrency} - // The primary and secondary currencies are subject to change based on the user's settings - // TODO: rename this primary/secondary concept here to be more intuitive, regardless of setting - primary={isOriginalNativeSymbol ? secondaryCurrencyDisplay : undefined} - tokenSymbol={ - showNativeTokenAsMainBalance - ? primaryCurrencyProperties.suffix - : secondaryCurrencyProperties.suffix - } - secondary={ - showFiat && isOriginalNativeSymbol - ? primaryCurrencyDisplay - : undefined - } - tokenImage={balanceIsLoading ? null : primaryTokenImage} - isOriginalTokenSymbol={isOriginalNativeSymbol} - isNativeCurrency - isStakeable={isStakeable} - showPercentage - /> - { - onClickAsset(tokenAddress); - trackEvent({ - event: MetaMetricsEventName.TokenScreenOpened, - category: MetaMetricsEventCategory.Navigation, - properties: { - token_symbol: primaryCurrencyProperties.suffix, - location: 'Home', - }, - }); - }} - /> {shouldShowTokensLinks && ( { + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); + const t = useI18nContext(); + const isEvm = useSelector(getMultichainIsEvm); + // NOTE: Since we can parametrize it now, we keep the original behavior + // for EVM assets + const shouldShowTokensLinks = showTokensLinks ?? isEvm; + + return ( + { + dispatch(showImportTokensModal()); + trackEvent({ + category: MetaMetricsEventCategory.Navigation, + event: MetaMetricsEventName.TokenImportButtonClicked, + properties: { + location: 'HOME', + }, + }); + }} + > + {t('import')} + + ); +}; + +export default AssetListControlBar; diff --git a/ui/components/app/assets/asset-list/import-control/index.ts b/ui/components/app/assets/asset-list/import-control/index.ts new file mode 100644 index 000000000000..b871f41ae8b4 --- /dev/null +++ b/ui/components/app/assets/asset-list/import-control/index.ts @@ -0,0 +1 @@ +export { default } from './import-control'; diff --git a/ui/components/app/assets/asset-list/native-token/index.ts b/ui/components/app/assets/asset-list/native-token/index.ts new file mode 100644 index 000000000000..6feb276bed54 --- /dev/null +++ b/ui/components/app/assets/asset-list/native-token/index.ts @@ -0,0 +1 @@ +export { default } from './native-token'; diff --git a/ui/components/app/assets/asset-list/native-token/native-token.tsx b/ui/components/app/assets/asset-list/native-token/native-token.tsx new file mode 100644 index 000000000000..cf0191b3de66 --- /dev/null +++ b/ui/components/app/assets/asset-list/native-token/native-token.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { + getMultichainCurrentNetwork, + getMultichainNativeCurrency, + getMultichainIsEvm, + getMultichainCurrencyImage, + getMultichainIsMainnet, + getMultichainSelectedAccountCachedBalance, +} from '../../../../../selectors/multichain'; +import { TokenListItem } from '../../../../multichain'; +import { useIsOriginalNativeTokenSymbol } from '../../../../../hooks/useIsOriginalNativeTokenSymbol'; +import { AssetListProps } from '../asset-list'; +import { useNativeTokenBalance } from './use-native-token-balance'; +// import { getPreferences } from '../../../../../selectors'; + +const NativeToken = ({ onClickAsset }: AssetListProps) => { + const nativeCurrency = useSelector(getMultichainNativeCurrency); + const isMainnet = useSelector(getMultichainIsMainnet); + const { chainId, ticker, type, rpcUrl } = useSelector( + getMultichainCurrentNetwork, + ); + const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( + chainId, + ticker, + type, + rpcUrl, + ); + const balance = useSelector(getMultichainSelectedAccountCachedBalance); + const balanceIsLoading = !balance; + + const { string, symbol, secondary } = useNativeTokenBalance(); + + const primaryTokenImage = useSelector(getMultichainCurrencyImage); + + const isEvm = useSelector(getMultichainIsEvm); + + let isStakeable = isMainnet && isEvm; + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + isStakeable = false; + ///: END:ONLY_INCLUDE_IF + + return ( + onClickAsset(nativeCurrency)} + title={nativeCurrency} + primary={string} + tokenSymbol={symbol} + secondary={secondary} + tokenImage={balanceIsLoading ? null : primaryTokenImage} + isOriginalTokenSymbol={isOriginalNativeSymbol} + isNativeCurrency + isStakeable={isStakeable} + showPercentage + /> + ); +}; + +export default NativeToken; diff --git a/ui/components/app/assets/asset-list/native-token/use-native-token-balance.ts b/ui/components/app/assets/asset-list/native-token/use-native-token-balance.ts new file mode 100644 index 000000000000..a14e65ac572b --- /dev/null +++ b/ui/components/app/assets/asset-list/native-token/use-native-token-balance.ts @@ -0,0 +1,94 @@ +import currencyFormatter from 'currency-formatter'; +import { useSelector } from 'react-redux'; + +import { + getMultichainCurrencyImage, + getMultichainCurrentNetwork, + getMultichainSelectedAccountCachedBalance, + getMultichainShouldShowFiat, +} from '../../../../../selectors/multichain'; +import { getCurrentCurrency, getPreferences } from '../../../../../selectors'; +import { useIsOriginalNativeTokenSymbol } from '../../../../../hooks/useIsOriginalNativeTokenSymbol'; +import { PRIMARY, SECONDARY } from '../../../../../helpers/constants/common'; +import { useUserPreferencedCurrency } from '../../../../../hooks/useUserPreferencedCurrency'; +import { useCurrencyDisplay } from '../../../../../hooks/useCurrencyDisplay'; +import { TokenWithBalance } from '../asset-list'; + +export const useNativeTokenBalance = () => { + const showFiat = useSelector(getMultichainShouldShowFiat); + const primaryTokenImage = useSelector(getMultichainCurrencyImage); + const { showNativeTokenAsMainBalance } = useSelector(getPreferences); + const { chainId, ticker, type, rpcUrl } = useSelector( + getMultichainCurrentNetwork, + ); + const isOriginalNativeSymbol = useIsOriginalNativeTokenSymbol( + chainId, + ticker, + type, + rpcUrl, + ); + const balance = useSelector(getMultichainSelectedAccountCachedBalance); + const currentCurrency = useSelector(getCurrentCurrency); + const { + currency: primaryCurrency, + numberOfDecimals: primaryNumberOfDecimals, + } = useUserPreferencedCurrency(PRIMARY, { + ethNumberOfDecimals: 4, + shouldCheckShowNativeToken: true, + }); + const { + currency: secondaryCurrency, + numberOfDecimals: secondaryNumberOfDecimals, + } = useUserPreferencedCurrency(SECONDARY, { + ethNumberOfDecimals: 4, + shouldCheckShowNativeToken: true, + }); + + const [primaryCurrencyDisplay, primaryCurrencyProperties] = + useCurrencyDisplay(balance, { + numberOfDecimals: primaryNumberOfDecimals, + currency: primaryCurrency, + }); + + const [secondaryCurrencyDisplay, secondaryCurrencyProperties] = + useCurrencyDisplay(balance, { + numberOfDecimals: secondaryNumberOfDecimals, + currency: secondaryCurrency, + }); + + const primaryBalance = isOriginalNativeSymbol + ? secondaryCurrencyDisplay + : undefined; + + const secondaryBalance = + showFiat && isOriginalNativeSymbol ? primaryCurrencyDisplay : undefined; + + const tokenSymbol = showNativeTokenAsMainBalance + ? primaryCurrencyProperties.suffix + : secondaryCurrencyProperties.suffix; + + const unformattedTokenFiatAmount = showNativeTokenAsMainBalance + ? secondaryCurrencyDisplay.toString() + : primaryCurrencyDisplay.toString(); + + // useCurrencyDisplay passes along the symbol and formatting into the value here + // for sorting we need the raw value, without the currency and it should be decimal + // this is the easiest way to do this without extensive refactoring of useCurrencyDisplay + const tokenFiatAmount = currencyFormatter + .unformat(unformattedTokenFiatAmount, { + code: currentCurrency.toUpperCase(), + }) + .toString(); + + const nativeTokenWithBalance: TokenWithBalance = { + address: '', + symbol: tokenSymbol ?? '', + string: primaryBalance, + image: primaryTokenImage, + secondary: secondaryBalance, + tokenFiatAmount, + isNative: true, + }; + + return nativeTokenWithBalance; +}; diff --git a/ui/components/app/assets/asset-list/sort-control/index.scss b/ui/components/app/assets/asset-list/sort-control/index.scss new file mode 100644 index 000000000000..76e61c1025ae --- /dev/null +++ b/ui/components/app/assets/asset-list/sort-control/index.scss @@ -0,0 +1,27 @@ +.selectable-list-item-wrapper { + position: relative; +} + +.selectable-list-item { + cursor: pointer; + padding: 16px; + + &--selected { + background: var(--color-primary-muted); + } + + &:not(.selectable-list-item--selected) { + &:hover, + &:focus-within { + background: var(--color-background-default-hover); + } + } + + &__selected-indicator { + width: 4px; + height: calc(100% - 8px); + position: absolute; + top: 4px; + left: 4px; + } +} diff --git a/ui/components/app/assets/asset-list/sort-control/index.ts b/ui/components/app/assets/asset-list/sort-control/index.ts new file mode 100644 index 000000000000..7e5ecace780f --- /dev/null +++ b/ui/components/app/assets/asset-list/sort-control/index.ts @@ -0,0 +1 @@ +export { default } from './sort-control'; diff --git a/ui/components/app/assets/asset-list/sort-control/sort-control.test.tsx b/ui/components/app/assets/asset-list/sort-control/sort-control.test.tsx new file mode 100644 index 000000000000..4aac598bd838 --- /dev/null +++ b/ui/components/app/assets/asset-list/sort-control/sort-control.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { useSelector } from 'react-redux'; +import { setTokenSortConfig } from '../../../../../store/actions'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers'; +import { MetaMetricsContext } from '../../../../../contexts/metametrics'; +import { getCurrentCurrency, getPreferences } from '../../../../../selectors'; +import SortControl from './sort-control'; + +// Mock the sortAssets utility +jest.mock('../../util/sort', () => ({ + sortAssets: jest.fn(() => []), // mock sorting implementation +})); + +// Mock the setTokenSortConfig action creator +jest.mock('../../../../../store/actions', () => ({ + setTokenSortConfig: jest.fn(), +})); + +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useSelector: jest.fn(), + useDispatch: () => mockDispatch, + }; +}); + +const mockHandleClose = jest.fn(); + +describe('SortControl', () => { + const mockTrackEvent = jest.fn(); + + const renderComponent = () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === getPreferences) { + return { + key: 'tokenFiatAmount', + sortCallback: 'stringNumeric', + order: 'dsc', + }; + } + if (selector === getCurrentCurrency) { + return 'usd'; + } + return undefined; + }); + + return renderWithProvider( + + + , + ); + }; + + beforeEach(() => { + mockDispatch.mockClear(); + mockTrackEvent.mockClear(); + (setTokenSortConfig as jest.Mock).mockClear(); + }); + + it('renders correctly', () => { + renderComponent(); + + expect(screen.getByTestId('sortByAlphabetically')).toBeInTheDocument(); + expect(screen.getByTestId('sortByDecliningBalance')).toBeInTheDocument(); + }); + + it('dispatches setTokenSortConfig with expected config, and tracks event when Alphabetically is clicked', () => { + renderComponent(); + + const alphabeticallyButton = screen.getByTestId( + 'sortByAlphabetically__button', + ); + fireEvent.click(alphabeticallyButton); + + expect(mockDispatch).toHaveBeenCalled(); + expect(setTokenSortConfig).toHaveBeenCalledWith({ + key: 'symbol', + sortCallback: 'alphaNumeric', + order: 'asc', + }); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + category: 'Settings', + event: 'Token Sort Preference', + properties: { + token_sort_preference: 'symbol', + }, + }); + }); + + it('dispatches setTokenSortConfig with expected config, and tracks event when Declining balance is clicked', () => { + renderComponent(); + + const decliningBalanceButton = screen.getByTestId( + 'sortByDecliningBalance__button', + ); + fireEvent.click(decliningBalanceButton); + + expect(mockDispatch).toHaveBeenCalled(); + expect(setTokenSortConfig).toHaveBeenCalledWith({ + key: 'tokenFiatAmount', + sortCallback: 'stringNumeric', + order: 'dsc', + }); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + category: 'Settings', + event: 'Token Sort Preference', + properties: { + token_sort_preference: 'tokenFiatAmount', + }, + }); + }); +}); diff --git a/ui/components/app/assets/asset-list/sort-control/sort-control.tsx b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx new file mode 100644 index 000000000000..c45a5488f1a6 --- /dev/null +++ b/ui/components/app/assets/asset-list/sort-control/sort-control.tsx @@ -0,0 +1,116 @@ +import React, { ReactNode, useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import classnames from 'classnames'; +import { Box, Text } from '../../../../component-library'; +import { SortOrder, SortingCallbacksT } from '../../util/sort'; +import { + BackgroundColor, + BorderRadius, + TextColor, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { setTokenSortConfig } from '../../../../../store/actions'; +import { MetaMetricsContext } from '../../../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsUserTrait, +} from '../../../../../../shared/constants/metametrics'; +import { getCurrentCurrency, getPreferences } from '../../../../../selectors'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { getCurrencySymbol } from '../../../../../helpers/utils/common.util'; + +// intentionally used generic naming convention for styled selectable list item +// inspired from ui/components/multichain/network-list-item +// should probably be broken out into component library +type SelectableListItemProps = { + isSelected: boolean; + onClick?: React.MouseEventHandler; + testId?: string; + children: ReactNode; +}; + +export const SelectableListItem = ({ + isSelected, + onClick, + testId, + children, +}: SelectableListItemProps) => { + return ( + + + + {children} + + + {isSelected && ( + + )} + + ); +}; + +type SortControlProps = { + handleClose: () => void; +}; + +const SortControl = ({ handleClose }: SortControlProps) => { + const t = useI18nContext(); + const trackEvent = useContext(MetaMetricsContext); + const { tokenSortConfig } = useSelector(getPreferences); + const currentCurrency = useSelector(getCurrentCurrency); + + const dispatch = useDispatch(); + + const handleSort = ( + key: string, + sortCallback: keyof SortingCallbacksT, + order: SortOrder, + ) => { + dispatch( + setTokenSortConfig({ + key, + sortCallback, + order, + }), + ); + trackEvent({ + category: MetaMetricsEventCategory.Settings, + event: MetaMetricsEventName.TokenSortPreference, + properties: { + [MetaMetricsUserTrait.TokenSortPreference]: key, + }, + }); + handleClose(); + }; + return ( + <> + handleSort('symbol', 'alphaNumeric', 'asc')} + testId="sortByAlphabetically" + > + {t('sortByAlphabetically')} + + handleSort('tokenFiatAmount', 'stringNumeric', 'dsc')} + testId="sortByDecliningBalance" + > + {t('sortByDecliningBalance', [getCurrencySymbol(currentCurrency)])} + + + ); +}; + +export default SortControl; diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 2cd5cb84b8ab..5f5b43d6c098 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -10,7 +10,7 @@ import { getIntlLocale } from '../../../../ducks/locale/locale'; type TokenCellProps = { address: string; symbol: string; - string: string; + string?: string; image: string; onClick?: (arg: string) => void; }; diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 194ea2762191..8a107b154fb9 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { ReactNode, useMemo } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; import TokenCell from '../token-cell'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { Box } from '../../../component-library'; @@ -8,39 +9,87 @@ import { JustifyContent, } from '../../../../helpers/constants/design-system'; import { TokenWithBalance } from '../asset-list/asset-list'; +import { sortAssets } from '../util/sort'; +import { + getPreferences, + getSelectedAccount, + getShouldHideZeroBalanceTokens, + getTokenExchangeRates, +} from '../../../../selectors'; +import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; +import { getConversionRate } from '../../../../ducks/metamask/metamask'; +import { useNativeTokenBalance } from '../asset-list/native-token/use-native-token-balance'; type TokenListProps = { onTokenClick: (arg: string) => void; - tokens: TokenWithBalance[]; - loading: boolean; + nativeToken: ReactNode; }; export default function TokenList({ onTokenClick, - tokens, - loading = false, + nativeToken, }: TokenListProps) { const t = useI18nContext(); + const { tokenSortConfig } = useSelector(getPreferences); + const selectedAccount = useSelector(getSelectedAccount); + const conversionRate = useSelector(getConversionRate); + const nativeTokenWithBalance = useNativeTokenBalance(); + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + const contractExchangeRates = useSelector( + getTokenExchangeRates, + shallowEqual, + ); + const { tokensWithBalances, loading } = useAccountTotalFiatBalance( + selectedAccount, + shouldHideZeroBalanceTokens, + ) as { + tokensWithBalances: TokenWithBalance[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mergedRates: any; + loading: boolean; + }; - if (loading) { - return ( - - {t('loadingTokens')} - + const sortedTokens = useMemo(() => { + return sortAssets( + [nativeTokenWithBalance, ...tokensWithBalances], + tokenSortConfig, ); - } + }, [ + tokensWithBalances, + tokenSortConfig, + conversionRate, + contractExchangeRates, + ]); - return ( + return loading ? ( + + {t('loadingTokens')} + + ) : (
- {tokens.map((tokenData, index) => ( - - ))} + {sortedTokens.map((tokenData) => { + if (tokenData?.isNative) { + // we need cloneElement so that we can pass the unique key + return React.cloneElement(nativeToken as React.ReactElement, { + key: `${tokenData.symbol}-${tokenData.address}`, + }); + } + return ( + + ); + })}
); } diff --git a/ui/components/app/assets/util/sort.test.ts b/ui/components/app/assets/util/sort.test.ts new file mode 100644 index 000000000000..f4a99e31b641 --- /dev/null +++ b/ui/components/app/assets/util/sort.test.ts @@ -0,0 +1,263 @@ +import { sortAssets } from './sort'; + +type MockAsset = { + name: string; + balance: string; + createdAt: Date; + profile: { + id: string; + info?: { + category?: string; + }; + }; +}; + +const mockAssets: MockAsset[] = [ + { + name: 'Asset Z', + balance: '500', + createdAt: new Date('2023-01-01'), + profile: { id: '1', info: { category: 'gold' } }, + }, + { + name: 'Asset A', + balance: '600', + createdAt: new Date('2022-05-15'), + profile: { id: '4', info: { category: 'silver' } }, + }, + { + name: 'Asset B', + balance: '400', + createdAt: new Date('2021-07-20'), + profile: { id: '2', info: { category: 'bronze' } }, + }, +]; + +// Define the sorting tests +describe('sortAssets function - nested value handling with dates and numeric sorting', () => { + test('sorts by name in ascending order', () => { + const sortedById = sortAssets(mockAssets, { + key: 'name', + sortCallback: 'alphaNumeric', + order: 'asc', + }); + + expect(sortedById[0].name).toBe('Asset A'); + expect(sortedById[sortedById.length - 1].name).toBe('Asset Z'); + }); + + test('sorts by balance in ascending order (stringNumeric)', () => { + const sortedById = sortAssets(mockAssets, { + key: 'balance', + sortCallback: 'stringNumeric', + order: 'asc', + }); + + expect(sortedById[0].balance).toBe('400'); + expect(sortedById[sortedById.length - 1].balance).toBe('600'); + }); + + test('sorts by balance in ascending order (numeric)', () => { + const sortedById = sortAssets(mockAssets, { + key: 'balance', + sortCallback: 'numeric', + order: 'asc', + }); + + expect(sortedById[0].balance).toBe('400'); + expect(sortedById[sortedById.length - 1].balance).toBe('600'); + }); + + test('sorts by profile.id in ascending order', () => { + const sortedById = sortAssets(mockAssets, { + key: 'profile.id', + sortCallback: 'stringNumeric', + order: 'asc', + }); + + expect(sortedById[0].profile.id).toBe('1'); + expect(sortedById[sortedById.length - 1].profile.id).toBe('4'); + }); + + test('sorts by profile.id in descending order', () => { + const sortedById = sortAssets(mockAssets, { + key: 'profile.id', + sortCallback: 'stringNumeric', + order: 'dsc', + }); + + expect(sortedById[0].profile.id).toBe('4'); + expect(sortedById[sortedById.length - 1].profile.id).toBe('1'); + }); + + test('sorts by deeply nested profile.info.category in ascending order', () => { + const sortedByCategory = sortAssets(mockAssets, { + key: 'profile.info.category', + sortCallback: 'alphaNumeric', + order: 'asc', + }); + + // Expecting the assets with defined categories to be sorted first + expect(sortedByCategory[0].profile.info?.category).toBe('bronze'); + expect( + sortedByCategory[sortedByCategory.length - 1].profile.info?.category, + ).toBe('silver'); + }); + + test('sorts by createdAt (date) in ascending order', () => { + const sortedByDate = sortAssets(mockAssets, { + key: 'createdAt', + sortCallback: 'date', + order: 'asc', + }); + + expect(sortedByDate[0].createdAt).toEqual(new Date('2021-07-20')); + expect(sortedByDate[sortedByDate.length - 1].createdAt).toEqual( + new Date('2023-01-01'), + ); + }); + + test('sorts by createdAt (date) in descending order', () => { + const sortedByDate = sortAssets(mockAssets, { + key: 'createdAt', + sortCallback: 'date', + order: 'dsc', + }); + + expect(sortedByDate[0].createdAt).toEqual(new Date('2023-01-01')); + expect(sortedByDate[sortedByDate.length - 1].createdAt).toEqual( + new Date('2021-07-20'), + ); + }); + + test('handles undefined deeply nested value gracefully when sorting', () => { + const invlaidAsset = { + name: 'Asset Y', + balance: '600', + createdAt: new Date('2024-01-01'), + profile: { id: '3' }, // No category info + }; + const sortedByCategory = sortAssets([...mockAssets, invlaidAsset], { + key: 'profile.info.category', + sortCallback: 'alphaNumeric', + order: 'asc', + }); + + // Expect the undefined categories to be at the end + expect( + // @ts-expect-error // testing for undefined value + sortedByCategory[sortedByCategory.length - 1].profile.info?.category, + ).toBeUndefined(); + }); +}); + +// Utility function to generate large mock data +function generateLargeMockData(size: number): MockAsset[] { + const mockData: MockAsset[] = []; + for (let i = 0; i < size; i++) { + mockData.push({ + name: `Asset ${String.fromCharCode(65 + (i % 26))}`, + balance: `${Math.floor(Math.random() * 1000)}`, // Random balance between 0 and 999 + createdAt: new Date(Date.now() - Math.random() * 10000000000), // Random date within the past ~115 days + profile: { + id: `${i + 1}`, + info: { + category: ['gold', 'silver', 'bronze'][i % 3], // Cycles between 'gold', 'silver', 'bronze' + }, + }, + }); + } + return mockData; +} + +// Generate a large dataset for testing +const largeDataset = generateLargeMockData(10000); // 10,000 mock assets + +// Define the sorting tests for large datasets +describe('sortAssets function - large dataset handling', () => { + const MAX_EXECUTION_TIME_MS = 500; // Set max allowed execution time (in milliseconds) + + test('sorts large dataset by name in ascending order', () => { + const startTime = Date.now(); + const sortedByName = sortAssets(largeDataset, { + key: 'name', + sortCallback: 'alphaNumeric', + order: 'asc', + }); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + expect(sortedByName[0].name).toBe('Asset A'); + expect(sortedByName[sortedByName.length - 1].name).toBe('Asset Z'); + expect(executionTime).toBeLessThan(MAX_EXECUTION_TIME_MS); + }); + + test('sorts large dataset by balance in ascending order', () => { + const startTime = Date.now(); + const sortedByBalance = sortAssets(largeDataset, { + key: 'balance', + sortCallback: 'numeric', + order: 'asc', + }); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + const balances = sortedByBalance.map((asset) => asset.balance); + expect(balances).toEqual( + balances.slice().sort((a, b) => parseInt(a, 10) - parseInt(b, 10)), + ); + expect(executionTime).toBeLessThan(MAX_EXECUTION_TIME_MS); + }); + + test('sorts large dataset by balance in descending order', () => { + const startTime = Date.now(); + const sortedByBalance = sortAssets(largeDataset, { + key: 'balance', + sortCallback: 'numeric', + order: 'dsc', + }); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + const balances = sortedByBalance.map((asset) => asset.balance); + expect(balances).toEqual( + balances.slice().sort((a, b) => parseInt(b, 10) - parseInt(a, 10)), + ); + expect(executionTime).toBeLessThan(MAX_EXECUTION_TIME_MS); + }); + + test('sorts large dataset by createdAt (date) in ascending order', () => { + const startTime = Date.now(); + const sortedByDate = sortAssets(largeDataset, { + key: 'createdAt', + sortCallback: 'date', + order: 'asc', + }); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + const dates = sortedByDate.map((asset) => asset.createdAt.getTime()); + expect(dates).toEqual(dates.slice().sort((a, b) => a - b)); + expect(executionTime).toBeLessThan(MAX_EXECUTION_TIME_MS); + }); + + test('sorts large dataset by createdAt (date) in descending order', () => { + const startTime = Date.now(); + const sortedByDate = sortAssets(largeDataset, { + key: 'createdAt', + sortCallback: 'date', + order: 'dsc', + }); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + const dates = sortedByDate.map((asset) => asset.createdAt.getTime()); + expect(dates).toEqual(dates.slice().sort((a, b) => b - a)); + expect(executionTime).toBeLessThan(MAX_EXECUTION_TIME_MS); + }); +}); diff --git a/ui/components/app/assets/util/sort.ts b/ui/components/app/assets/util/sort.ts new file mode 100644 index 000000000000..b24a1c8e96a9 --- /dev/null +++ b/ui/components/app/assets/util/sort.ts @@ -0,0 +1,86 @@ +import { get } from 'lodash'; + +export type SortOrder = 'asc' | 'dsc'; +export type SortCriteria = { + key: string; + order?: 'asc' | 'dsc'; + sortCallback: SortCallbackKeys; +}; + +export type SortingType = number | string | Date; +type SortCallbackKeys = keyof SortingCallbacksT; + +export type SortingCallbacksT = { + numeric: (a: number, b: number) => number; + stringNumeric: (a: string, b: string) => number; + alphaNumeric: (a: string, b: string) => number; + date: (a: Date, b: Date) => number; +}; + +// All sortingCallbacks should be asc order, sortAssets function handles asc/dsc +const sortingCallbacks: SortingCallbacksT = { + numeric: (a: number, b: number) => a - b, + stringNumeric: (a: string, b: string) => { + return ( + parseFloat(parseFloat(a).toFixed(5)) - + parseFloat(parseFloat(b).toFixed(5)) + ); + }, + alphaNumeric: (a: string, b: string) => a.localeCompare(b), + date: (a: Date, b: Date) => a.getTime() - b.getTime(), +}; + +// Utility function to access nested properties by key path +function getNestedValue(obj: T, keyPath: string): SortingType { + return get(obj, keyPath) as SortingType; +} + +export function sortAssets(array: T[], criteria: SortCriteria): T[] { + const { key, order = 'asc', sortCallback } = criteria; + + return [...array].sort((a, b) => { + const aValue = getNestedValue(a, key); + const bValue = getNestedValue(b, key); + + // Always move undefined values to the end, regardless of sort order + if (aValue === undefined) { + return 1; + } + + if (bValue === undefined) { + return -1; + } + + let comparison: number; + + switch (sortCallback) { + case 'stringNumeric': + case 'alphaNumeric': + comparison = sortingCallbacks[sortCallback]( + aValue as string, + bValue as string, + ); + break; + case 'numeric': + comparison = sortingCallbacks.numeric( + aValue as number, + bValue as number, + ); + break; + case 'date': + comparison = sortingCallbacks.date(aValue as Date, bValue as Date); + break; + default: + if (aValue < bValue) { + comparison = -1; + } else if (aValue > bValue) { + comparison = 1; + } else { + comparison = 0; + } + } + + // Modify to sort in ascending or descending order + return order === 'asc' ? comparison : -comparison; + }); +} diff --git a/ui/components/multichain/account-overview/account-overview-btc.test.tsx b/ui/components/multichain/account-overview/account-overview-btc.test.tsx index 34cbed54c127..9d265657432b 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-btc.test.tsx @@ -40,7 +40,9 @@ describe('AccountOverviewBtc', () => { const { queryByTestId } = render(); expect(queryByTestId('account-overview__asset-tab')).toBeInTheDocument(); - expect(queryByTestId('import-token-button')).not.toBeInTheDocument(); + const button = queryByTestId('import-token-button'); + expect(button).toBeInTheDocument(); // Verify the button is present + expect(button).toBeDisabled(); // Verify the button is disabled // TODO: This one might be required, but we do not really handle tokens for BTC yet... expect(queryByTestId('refresh-list-button')).not.toBeInTheDocument(); }); diff --git a/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap b/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap index 9dd39fea147f..e8fa1e945dba 100644 --- a/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap +++ b/ui/components/multichain/import-token-link/__snapshots__/import-token-link.test.js.snap @@ -5,20 +5,6 @@ exports[`Import Token Link should match snapshot for goerli chainId 1`] = `