diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index 2735ef07f..e03a7be17 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline"; import { NETWORKS, TransactionContext } from "config/constants"; import { useCollectiblesStore } from "ducks/collectibles"; +import { useBalancesList } from "hooks/useBalancesList"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; import React from "react"; import { @@ -42,9 +43,10 @@ jest.mock("hooks/useAppTranslation", () => ({ t: (key: string) => ({ "balancesList.title": "Tokens", + "balancesList.error": "Error loading balances", "collectiblesGrid.title": "Collectibles", "collectiblesGrid.error": "Error loading collectibles", - "collectiblesGrid.empty": "No collectibles", + "transactionTokenScreen.empty": "No tokens or collectibles to send", })[key] || key, }), })); @@ -65,82 +67,323 @@ jest.mock("ducks/collectibles", () => ({ useCollectiblesStore: jest.fn(), })); +jest.mock("hooks/useBalancesList", () => ({ + useBalancesList: jest.fn(), +})); + jest.mock("hooks/useFilteredCollectibles", () => ({ useFilteredCollectibles: jest.fn(), })); const mockUseCollectiblesStore = useCollectiblesStore as unknown as jest.MockedFunction; +const mockUseBalancesList = + useBalancesList as unknown as jest.MockedFunction; const mockUseFilteredCollectibles = useFilteredCollectibles as unknown as jest.MockedFunction; -const setupCollectibleState = ({ - isLoading = false, - error = null, +const setupState = ({ + // Tokens + tokensLoading = false, + tokensError = null, + noBalances = false, + // Collectibles + collectiblesLoading = false, + collectiblesError = null, visibleCollectibles = [], }: { - isLoading?: boolean; - error?: string | null; + tokensLoading?: boolean; + tokensError?: string | null; + noBalances?: boolean; + collectiblesLoading?: boolean; + collectiblesError?: string | null; visibleCollectibles?: any[]; }) => { + mockUseBalancesList.mockReturnValue({ + isLoading: tokensLoading, + error: tokensError, + noBalances, + }); mockUseCollectiblesStore.mockImplementation((selector: any) => - selector({ isLoading, error }), + selector({ isLoading: collectiblesLoading, error: collectiblesError }), ); mockUseFilteredCollectibles.mockReturnValue({ visibleCollectibles }); }; +const renderComponent = (props = {}) => + render( + , + ); + describe("TokensCollectiblesInline", () => { beforeEach(() => { jest.clearAllMocks(); }); - it("shows loading spinner while collectibles are loading", () => { - setupCollectibleState({ isLoading: true, visibleCollectibles: [] }); + it("shows a single spinner while tokens are still loading", () => { + setupState({ tokensLoading: true, noBalances: true }); - render( - , - ); + renderComponent(); - expect(screen.getByTestId("collectibles-inline-spinner")).toBeTruthy(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); }); - it("shows collectibles error state", () => { - setupCollectibleState({ error: "failed", visibleCollectibles: [] }); + it("shows a single spinner while collectibles are still loading", () => { + setupState({ collectiblesLoading: true, visibleCollectibles: [] }); + + renderComponent(); - render( - , - ); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + }); + it("does not blank the funded token list while tokens refresh in the background", () => { + // tokensLoading is true but balances are present (noBalances false), so the + // spinner must NOT show — matching BalancesList's own gate, the funded list + // stays visible during a background refresh. + setupState({ + tokensLoading: true, + noBalances: false, + visibleCollectibles: [], + }); + + renderComponent(); + + expect( + screen.queryByTestId("tokens-collectibles-inline-spinner"), + ).toBeNull(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + }); + + it("shows the collectibles error beneath its header when only collectibles fail", () => { + setupState({ collectiblesError: "failed", noBalances: true }); + + renderComponent(); + + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect( + screen.getByTestId("collectibles-inline-error"), + ).toBeTruthy(); expect(screen.getByText("Error loading collectibles")).toBeTruthy(); }); - it("shows collectibles empty state", () => { - setupCollectibleState({ visibleCollectibles: [] }); + it("keeps the spinner up while one source loads even if the other has errored", () => { + // We wait for both sources before rendering content, so a settled token + // error stays hidden behind the spinner until collectibles finish loading. + setupState({ + tokensError: "token-failed", + noBalances: true, + collectiblesLoading: true, + visibleCollectibles: [], + }); + + renderComponent(); + + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + expect(screen.queryByTestId("tokens-inline-error")).toBeNull(); + expect(screen.queryByText("Error loading balances")).toBeNull(); + }); + + it("renders the tokens list alongside the collectibles error when only collectibles fail", () => { + setupState({ noBalances: false, collectiblesError: "failed" }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading collectibles")).toBeTruthy(); + }); + + it("renders the collectibles alongside the tokens error when only tokens fail", () => { + setupState({ + tokensError: "token-failed", + noBalances: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("tokens-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading balances")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByText("Collectible #42")).toBeTruthy(); + }); + + it("keeps the loaded collectibles visible while they reload in the background", () => { + // Once collectibles have loaded, a background refetch (which flips + // isLoading without clearing the data) must not blank the page back to a + // spinner. The network/account can't change while the send flow is + // mounted, so the loaded data is always current. + setupState({ + noBalances: true, + collectiblesLoading: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "1", + image: "https://example.com/item.png", + name: "Collectible #1", + }, + ], + }, + ], + }); + + renderComponent(); + + expect( + screen.queryByTestId("tokens-collectibles-inline-spinner"), + ).toBeNull(); + expect(screen.getByText("Collectible #1")).toBeTruthy(); + }); + + it("suppresses a stale collectibles error while a retry is loading", () => { + // The collectibles store keeps the old error set while re-fetching, so a + // retry-in-flight (loading + stale error) should show the spinner, not the + // stale error. + setupState({ + noBalances: true, + collectiblesLoading: true, + collectiblesError: "stale-failure", + visibleCollectibles: [], + }); + + renderComponent(); + + expect(screen.queryByTestId("collectibles-inline-error")).toBeNull(); + expect( + screen.getByTestId("tokens-collectibles-inline-spinner"), + ).toBeTruthy(); + }); + + it("shows both section errors when both sources fail", () => { + setupState({ + tokensError: "token-failed", + noBalances: true, + collectiblesError: "collectibles-failed", + }); + + renderComponent(); + + expect(screen.getByTestId("tokens-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading balances")).toBeTruthy(); + expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy(); + expect(screen.getByText("Error loading collectibles")).toBeTruthy(); + }); + + it("shows the combined empty fallback when there are no tokens or collectibles", () => { + setupState({ noBalances: true, visibleCollectibles: [] }); + + renderComponent(); + + expect( + screen.getByText("No tokens or collectibles to send"), + ).toBeTruthy(); + }); + + it("hides the collectibles section when there are no collectibles", () => { + setupState({ noBalances: false, visibleCollectibles: [] }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.queryByText("Collectibles")).toBeNull(); + expect( + screen.queryByText("No tokens or collectibles to send"), + ).toBeNull(); + }); + + it("renders both the tokens and collectibles sections when both are present", () => { + setupState({ + noBalances: false, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); + + renderComponent(); + + expect(screen.getByText("Tokens")).toBeTruthy(); + expect(screen.getByTestId("balances-list-token-row")).toBeTruthy(); + expect(screen.getByText("Collectibles")).toBeTruthy(); + expect(screen.getByText("Collectible #42")).toBeTruthy(); + expect( + screen.queryByText("No tokens or collectibles to send"), + ).toBeNull(); + }); + + it("hides the tokens section when there are no balances", () => { + setupState({ + noBalances: true, + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); - render( - , - ); + renderComponent(); - expect(screen.getByText("No collectibles")).toBeTruthy(); + expect(screen.queryByTestId("balances-list-token-row")).toBeNull(); + expect(screen.getByText("Collectibles")).toBeTruthy(); }); it("forwards token and collectible press handlers", () => { const onTokenPress = jest.fn(); const onCollectiblePress = jest.fn(); - setupCollectibleState({ + setupState({ visibleCollectibles: [ { collectionAddress: "CABC", @@ -157,15 +400,7 @@ describe("TokensCollectiblesInline", () => { ], }); - render( - , - ); + renderComponent({ onTokenPress, onCollectiblePress }); fireEvent.press(screen.getByTestId("balances-list-token-row")); expect(onTokenPress).toHaveBeenCalledWith("native"); diff --git a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx index f5f37a595..98b7c66e5 100644 --- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx +++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx @@ -11,6 +11,7 @@ import { import { useCollectiblesStore } from "ducks/collectibles"; import { pxValue } from "helpers/dimensions"; import useAppTranslation from "hooks/useAppTranslation"; +import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; import React from "react"; @@ -45,83 +46,177 @@ export const TokensCollectiblesInline: React.FC< }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - const isLoading = useCollectiblesStore((state) => state.isLoading); - const error = useCollectiblesStore((state) => state.error); + + // Tokens state, lifted from the same hook BalancesList uses internally so we + // can drive a single page-level spinner/error. + const { isLoading: tokensLoading, error: tokensError, noBalances } = + useBalancesList({ publicKey, network }); + + // Collectibles state. + const collectiblesLoading = useCollectiblesStore((state) => state.isLoading); + const collectiblesError = useCollectiblesStore((state) => state.error); const { visibleCollectibles } = useFilteredCollectibles(); - const renderCollectiblesContent = () => { - if (isLoading) { - return ( - - - - ); - } + const hasTokens = !noBalances; + const hasCollectibles = visibleCollectibles.length > 0; - if (error) { - return ( - + // Single spinner: keep it up until both sources have data for the first time. + // + // Both sources gate on "no data yet" — tokens on noBalances (matching + // BalancesList's own spinner condition) and collectibles on !hasCollectibles. + // This waits for BOTH sources before any content renders, but once a section + // has data a later background refetch (which flips isLoading without clearing + // the existing data) no longer blanks the whole page back to a spinner. + // + // The collectibles store keeping stale data/error mid-refetch (it only flips + // isLoading) is not a concern here: the network and active account cannot + // change while the send flow is mounted, so the data on screen always belongs + // to the current account/network. + // + // Because the spinner covers the initial load of both sources, a per-section + // error only surfaces once loading has settled, so the collectibles store's + // stale-error-during-retry is masked and the per-section error checks below + // need no extra !collectiblesLoading guard. + const showSpinner = + (tokensLoading && noBalances) || (collectiblesLoading && !hasCollectibles); + + // Each source renders its own section independently: a failed source shows + // its (curated) error beneath its section header, while the other source + // still renders its content. A section is "shown" when it either has data or + // has errored. + const tokensSectionShown = Boolean(tokensError) || hasTokens; + const collectiblesSectionShown = Boolean(collectiblesError) || hasCollectibles; + + // The combined empty fallback only applies when both sources succeeded with + // no data; an error in either section takes its place instead. + const showEmpty = !tokensSectionShown && !collectiblesSectionShown; + + const renderSectionHeader = ( + icon: React.ReactNode, + title: string, + withTopMargin = false, + ) => ( + + {icon} + + {title} + + + ); + + const renderInlineError = (testID: string, message: string) => ( + + + {message} + + + ); + + const renderCollectibles = () => + visibleCollectibles.map((collection) => ( + + + + {collection.collectionName} + + - {t("collectiblesGrid.error")} + {collection.items.length} - ); - } - if (visibleCollectibles.length === 0) { + {collection.items.map((item) => ( + + onCollectiblePress?.({ + collectionAddress: item.collectionAddress, + tokenId: item.tokenId, + }) + } + > + + + + + + {item.name || `${collection.collectionName} #${item.tokenId}`} + + + ))} + + )); + + const renderContent = () => { + // Wait for both sources before rendering any content, so neither a + // section's data nor its error appears until loading has fully settled. + if (showSpinner) { return ( - - - - {t("collectiblesGrid.empty")} - + + ); } return ( <> - {visibleCollectibles.map((collection) => ( - - - - {collection.collectionName} - - - - {collection.items.length} - - + {tokensSectionShown && ( + <> + {renderSectionHeader( + , + t("balancesList.title"), + )} + + {tokensError ? ( + renderInlineError("tokens-inline-error", t("balancesList.error")) + ) : ( + + )} + + )} + + {collectiblesSectionShown && ( + <> + {renderSectionHeader( + , + t("collectiblesGrid.title"), + tokensSectionShown, + )} + + {collectiblesError + ? renderInlineError( + "collectibles-inline-error", + t("collectiblesGrid.error"), + ) + : renderCollectibles()} + + )} - {collection.items.map((item) => ( - - onCollectiblePress?.({ - collectionAddress: item.collectionAddress, - tokenId: item.tokenId, - }) - } - > - - - - - - {item.name || `${collection.collectionName} #${item.tokenId}`} - - - ))} + {showEmpty && ( + + + + {t("transactionTokenScreen.empty")} + - ))} + )} ); }; @@ -133,31 +228,7 @@ export const TokensCollectiblesInline: React.FC< keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingHorizontal: pxValue(DEFAULT_PADDING) }} > - - - - {t("balancesList.title")} - - - - - - - - - {t("collectiblesGrid.title")} - - - - {renderCollectiblesContent()} + {renderContent()} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 9178a257b..88caddc63 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -717,7 +717,8 @@ "minimumReceived": "Minimum received" }, "transactionTokenScreen": { - "title": "Sending" + "title": "Sending", + "empty": "No tokens or collectibles to send" }, "transactionSettings": { "title": "Transaction settings", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 26e318329..f9f29cfb8 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -679,7 +679,8 @@ "minimumReceived": "Mínimo recebido" }, "transactionTokenScreen": { - "title": "Enviando" + "title": "Enviando", + "empty": "Nenhum token ou colecionável para enviar" }, "transactionSettings": { "title": "Configurações da transação",