diff --git a/packages/widget/src/hooks/api/use-activity-actions.ts b/packages/widget/src/hooks/api/use-activity-actions.ts index 7680a61d..3bfc3d5a 100644 --- a/packages/widget/src/hooks/api/use-activity-actions.ts +++ b/packages/widget/src/hooks/api/use-activity-actions.ts @@ -1,13 +1,27 @@ -import { type QueryClient, useInfiniteQuery } from "@tanstack/react-query"; +import { + type QueryClient, + useInfiniteQuery, + useQuery, +} from "@tanstack/react-query"; import { EitherAsync } from "purify-ts"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { type ActionDto, getActionInputToken, getActionValidatorAddresses, } from "../../domain/types/action"; import type { Yield } from "../../domain/types/yields"; -import type { ValidatorDto } from "../../generated/api/yield"; +import type { + ActionsControllerGetActionsParams, + ValidatorDto, +} from "../../generated/api/yield"; +import { + type ActivityFilter, + activityFilterCategories, + getActivityFilterYieldTypes, +} from "../../pages/details/activity-page/activity-filters"; +import type { ActivityFilterOption } from "../../pages/details/activity-page/hooks/use-activity-filters"; +import type { ApiClient } from "../../providers/api/api-client"; import { useApiClient } from "../../providers/api/api-client-provider"; import { useSKQueryClient } from "../../providers/query-client"; import { useSKWallet } from "../../providers/sk-wallet"; @@ -15,7 +29,12 @@ import { getYieldOpportunity } from "./use-yield-opportunity/get-yield-opportuni import { getYieldValidatorsByAddresses } from "./use-yield-validators"; const PAGE_SIZE = 50; +const COUNT_PAGE_SIZE = 1; const ACTIVITY_VALIDATOR_ENRICHMENT_CONCURRENCY = 5; +const ACTIVITY_ACTION_STATUSES = [ + "SUCCESS", + "FAILED", +] as const satisfies NonNullable; type ActivityActionItem = { actionData: ActionDto; @@ -24,11 +43,131 @@ type ActivityActionItem = { }; type ActivityActionBaseItem = Omit; +type ActivityActionsPage = Awaited< + ReturnType +> & { + data: ActivityActionItem[]; +}; type UseActivityActionsResult = ReturnType & { allItems: ActivityActionItem[] | undefined; }; +type ActivityActionsRequestParams = { + address: string; + filter: ActivityFilter; + limit: number; + network: NonNullable; + offset: number; +}; + +type ActivityFilterOptionsParams = { + address: string; + apiClient: ApiClient; + network: NonNullable; + signal?: AbortSignal; +}; + +type FetchActivityActionsPageParams = { + address: string; + apiClient: ApiClient; + filter: ActivityFilter; + isLedgerLive: boolean; + network: NonNullable; + offset: number; + queryClient: QueryClient; + signal?: AbortSignal; + suppressRichErrors?: boolean; +}; + +export const getActivityActionsQueryKey = ({ + address, + filter, + network, +}: { + address: string | null | undefined; + filter: ActivityFilter; + network: ActionsControllerGetActionsParams["network"] | null | undefined; +}) => + [ + "activity-actions", + { + address, + filter, + network, + yieldTypes: getActivityFilterYieldTypes(filter), + }, + ] as const; + +const getActivityFilterOptionsQueryKey = ({ + address, + network, +}: { + address: string | null | undefined; + network: ActionsControllerGetActionsParams["network"] | null | undefined; +}) => ["activity-action-filter-options", { address, network }] as const; + +export const getActivityActionsRequestParams = ({ + address, + filter, + limit, + network, + offset, +}: ActivityActionsRequestParams): ActionsControllerGetActionsParams => { + const yieldTypes = getActivityFilterYieldTypes(filter); + + return { + address, + limit, + offset, + network, + // Pending actions are filtered out; only completed (SUCCESS) and retryable + // error (FAILED) actions are surfaced in the activity list. + statuses: ACTIVITY_ACTION_STATUSES, + ...(yieldTypes?.length ? { yieldTypes } : {}), + }; +}; + +export const fetchActivityFilterOptions = async ({ + address, + apiClient, + network, + signal, +}: ActivityFilterOptionsParams): Promise => { + const client = apiClient.withOptions({ signal, suppressRichErrors: true }); + const getCount = async (filter: ActivityFilter) => { + const result = await client.yield.ActionsControllerGetActions({ + params: getActivityActionsRequestParams({ + address, + filter, + limit: COUNT_PAGE_SIZE, + network, + offset: 0, + }), + }); + + return result.total; + }; + + const allCount = await getCount("all"); + + if (allCount <= 0) return []; + + const categoryOptions = await Promise.all( + activityFilterCategories.map(async (filter) => ({ + filter, + count: await getCount(filter), + })) + ); + const visibleCategoryOptions = categoryOptions.filter( + (option) => option.count > 0 + ); + + return visibleCategoryOptions.length > 0 + ? [{ filter: "all", count: allCount }, ...visibleCategoryOptions] + : []; +}; + const getItemsWithValidators = async ({ items, apiClient, @@ -69,75 +208,158 @@ const getItemsWithValidators = async ({ return data; }; -export const useActivityActions = (): UseActivityActionsResult => { +const getNextActivityActionsPageParam = (lastPage: ActivityActionsPage) => { + const nextOffset = (lastPage.offset ?? 0) + (lastPage.limit ?? PAGE_SIZE); + + return nextOffset < (lastPage.total ?? 0) ? nextOffset : undefined; +}; + +const fetchActivityActionsPage = async ({ + address, + apiClient, + filter, + isLedgerLive, + network, + offset, + queryClient, + signal, + suppressRichErrors, +}: FetchActivityActionsPageParams): Promise => { + return ( + await EitherAsync(() => + apiClient + .withOptions({ signal, suppressRichErrors }) + .yield.ActionsControllerGetActions({ + params: getActivityActionsRequestParams({ + address, + filter, + limit: PAGE_SIZE, + offset, + network, + }), + }) + ) + .mapLeft(() => new Error("Could not get action list")) + .chain(async (actionList) => + EitherAsync.all( + (actionList.items ?? []).map((action) => + getYieldOpportunity({ + yieldId: action.yieldId, + queryClient, + isLedgerLive, + apiClient, + suppressRichErrors: true, + }) + .map((yieldData) => ({ + actionData: action as ActionDto, + yieldData, + })) + .chainLeft(() => EitherAsync(() => Promise.resolve(null))) + ) + ) + .map((res) => res.filter((x) => x !== null)) + .map((res) => + res.filter( + (x) => + !!getActionInputToken({ + actionDto: x.actionData, + yieldDto: x.yieldData, + }) + ) + ) + .chain((items) => + EitherAsync(() => + getItemsWithValidators({ + items, + apiClient, + queryClient, + }) + ) + ) + .map((data) => ({ ...actionList, data })) + ) + ).unsafeCoerce(); +}; + +export const useActivityFilterOptions = (): ActivityFilterOption[] => { + const { address, network } = useSKWallet(); + const apiClient = useApiClient(); + + const query = useQuery({ + enabled: !!address && !!network, + queryKey: getActivityFilterOptionsQueryKey({ address, network }), + queryFn: async ({ signal }) => + fetchActivityFilterOptions({ + address: address!, + apiClient, + network: network!, + signal, + }).catch(() => []), + staleTime: 1000 * 60, + }); + + return query.data ?? []; +}; + +export const usePrefetchActivityActionFilters = ({ + filterOptions, +}: { + filterOptions: ActivityFilterOption[]; +}) => { + const { address, isLedgerLive, network } = useSKWallet(); + const queryClient = useSKQueryClient(); + const apiClient = useApiClient(); + + useEffect(() => { + if (!address || !network || filterOptions.length === 0) return; + + for (const { filter } of filterOptions) { + queryClient + .prefetchInfiniteQuery({ + queryKey: getActivityActionsQueryKey({ address, network, filter }), + queryFn: ({ pageParam = 0, signal }) => + fetchActivityActionsPage({ + address, + apiClient, + filter, + isLedgerLive, + network, + offset: pageParam as number, + queryClient, + signal, + suppressRichErrors: true, + }), + initialPageParam: 0, + getNextPageParam: getNextActivityActionsPageParam, + }) + .catch(() => undefined); + } + }, [address, apiClient, filterOptions, isLedgerLive, network, queryClient]); +}; + +export const useActivityActions = ( + filter: ActivityFilter = "all" +): UseActivityActionsResult => { const { address, isLedgerLive, network } = useSKWallet(); const queryClient = useSKQueryClient(); const apiClient = useApiClient(); const query = useInfiniteQuery({ enabled: !!address && !!network, - queryKey: ["activity-actions", address, network], - queryFn: async ({ pageParam = 0 }) => { - return ( - await EitherAsync(() => - apiClient.yield.ActionsControllerGetActions({ - params: { - address: address!, - limit: PAGE_SIZE, - offset: pageParam, - network: network!, - // Pending actions are filtered out; only completed (SUCCESS) and - // retryable error (FAILED) actions are surfaced in the activity list. - statuses: ["SUCCESS", "FAILED"], - }, - }) - ) - .mapLeft(() => new Error("Could not get action list")) - .chain(async (actionList) => - EitherAsync.all( - (actionList.items ?? []).map((action) => - getYieldOpportunity({ - yieldId: action.yieldId, - queryClient, - isLedgerLive, - apiClient, - suppressRichErrors: true, - }) - .map((yieldData) => ({ - actionData: action as ActionDto, - yieldData, - })) - .chainLeft(() => EitherAsync(() => Promise.resolve(null))) - ) - ) - .map((res) => res.filter((x) => x !== null)) - .map((res) => - res.filter( - (x) => - !!getActionInputToken({ - actionDto: x.actionData, - yieldDto: x.yieldData, - }) - ) - ) - .chain((items) => - EitherAsync(() => - getItemsWithValidators({ - items, - apiClient, - queryClient, - }) - ) - ) - .map((data) => ({ ...actionList, data })) - ) - ).unsafeCoerce(); - }, + queryKey: getActivityActionsQueryKey({ address, network, filter }), + queryFn: async ({ pageParam = 0, signal }) => + fetchActivityActionsPage({ + address: address!, + apiClient, + filter, + isLedgerLive, + network: network!, + offset: pageParam as number, + queryClient, + signal, + }), initialPageParam: 0, - getNextPageParam: (lastPage) => { - const nextOffset = (lastPage.offset ?? 0) + (lastPage.limit ?? PAGE_SIZE); - return nextOffset < (lastPage.total ?? 0) ? nextOffset : undefined; - }, + getNextPageParam: getNextActivityActionsPageParam, }); const allItems = useMemo( diff --git a/packages/widget/src/pages-dashboard/activity/activity.page.tsx b/packages/widget/src/pages-dashboard/activity/activity.page.tsx index 4083ebe1..bf8e16ec 100644 --- a/packages/widget/src/pages-dashboard/activity/activity.page.tsx +++ b/packages/widget/src/pages-dashboard/activity/activity.page.tsx @@ -16,6 +16,7 @@ import { useActivityPageContext, } from "../../pages/details/activity-page/state/activity-page.context"; import type { ActionYieldDto } from "../../pages/details/activity-page/types"; +import { FallbackContent } from "../../pages/details/positions-page/components/fallback-content"; import { useActivityContext } from "../../providers/activity-provider"; import { useSKWallet } from "../../providers/sk-wallet"; import { container } from "./styles.css"; @@ -24,7 +25,6 @@ const ActivityPageComponent = () => { const { content, allData, - filteredData, filterOptions, selectedFilter, onFilterSelect, @@ -32,6 +32,9 @@ const ActivityPageComponent = () => { total, onActionSelect, activityActions, + showActivityContent, + showActivityControls, + showActivityList, } = useActivityPage(); const { t } = useTranslation(); @@ -41,32 +44,52 @@ const ActivityPageComponent = () => { {content} - {!activityActions.isPending && allData && !!allData.length && ( + {showActivityContent && ( <> - - - - - {t("activity.showing_count", { showing: showingCount, total })} - - - - 80} - itemContent={(_index, item) => ( - - )} - /> + {showActivityControls && ( + + )} + + {showActivityList ? ( + <> + + + {t("activity.showing_count", { + showing: showingCount, + total, + })} + + + + 80} + itemContent={(_index, item) => ( + + )} + /> + + ) : ( + + + + )} )} diff --git a/packages/widget/src/pages/details/activity-page/activity-filters.ts b/packages/widget/src/pages/details/activity-page/activity-filters.ts index cdcacde1..9844dd33 100644 --- a/packages/widget/src/pages/details/activity-page/activity-filters.ts +++ b/packages/widget/src/pages/details/activity-page/activity-filters.ts @@ -1,25 +1,18 @@ import { type DashboardYieldCategory, - getDashboardYieldCategory, + getApiYieldTypesForDashboardCategory, } from "../../../domain/types/yields"; -import type { ActionYieldDto } from "./types"; +import type { ActionsControllerGetActionsParams } from "../../../generated/api/yield"; -export type ActivityFilterCategory = DashboardYieldCategory | "borrow"; +export type ActivityFilter = "all" | DashboardYieldCategory; -export type ActivityFilter = "all" | ActivityFilterCategory; - -/** - * Order in which the filter pills are rendered. "borrow" has no client-side - * signal yet and will only start matching once the API exposes a category per - * action; until then its pill stays hidden because no items resolve to it. - */ export const activityFilterCategories = [ "stake", "defi", "rwa", - "borrow", -] as const satisfies ReadonlyArray; +] as const satisfies ReadonlyArray; -export const getActivityFilterCategory = ( - action: ActionYieldDto -): ActivityFilterCategory | null => getDashboardYieldCategory(action.yieldData); +export const getActivityFilterYieldTypes = ( + filter: ActivityFilter +): ActionsControllerGetActionsParams["yieldTypes"] => + filter === "all" ? undefined : getApiYieldTypesForDashboardCategory(filter); diff --git a/packages/widget/src/pages/details/activity-page/activity.page.tsx b/packages/widget/src/pages/details/activity-page/activity.page.tsx index 4c4fd1d4..486cdf10 100644 --- a/packages/widget/src/pages/details/activity-page/activity.page.tsx +++ b/packages/widget/src/pages/details/activity-page/activity.page.tsx @@ -5,6 +5,7 @@ import { Text } from "../../../components/atoms/typography/text"; import { VirtualList } from "../../../components/atoms/virtual-list"; import { useMountAnimation } from "../../../providers/mount-animation"; import { PageContainer } from "../../components/page-container"; +import { FallbackContent } from "../positions-page/components/fallback-content"; import { ActionListItem } from "./components/action-list-item"; import { ActivityFilters } from "./components/activity-filters"; import { useActivityPage } from "./hooks/use-activity-page"; @@ -15,7 +16,6 @@ const ActivityPageComponent = () => { const { content, allData, - filteredData, filterOptions, selectedFilter, onFilterSelect, @@ -23,6 +23,9 @@ const ActivityPageComponent = () => { total, onActionSelect, activityActions, + showActivityContent, + showActivityControls, + showActivityList, } = useActivityPage(); const { t } = useTranslation(); @@ -32,32 +35,52 @@ const ActivityPageComponent = () => { {content} - {!activityActions.isPending && allData && !!allData.length && ( + {showActivityContent && ( <> - + {showActivityControls && ( + + )} - - - {t("activity.showing_count", { showing: showingCount, total })} - - + {showActivityList ? ( + <> + + + {t("activity.showing_count", { + showing: showingCount, + total, + })} + + - 80} - itemContent={(_index, item) => ( - - )} - /> + 80} + itemContent={(_index, item) => ( + + )} + /> + + ) : ( + + + + )} )} diff --git a/packages/widget/src/pages/details/activity-page/hooks/use-activity-filters.ts b/packages/widget/src/pages/details/activity-page/hooks/use-activity-filters.ts index f04deb3f..9830461d 100644 --- a/packages/widget/src/pages/details/activity-page/hooks/use-activity-filters.ts +++ b/packages/widget/src/pages/details/activity-page/hooks/use-activity-filters.ts @@ -1,11 +1,5 @@ import { useState } from "react"; -import { - type ActivityFilter, - type ActivityFilterCategory, - activityFilterCategories, - getActivityFilterCategory, -} from "../activity-filters"; -import type { ActionYieldDto } from "../types"; +import type { ActivityFilter } from "../activity-filters"; export type ActivityFilterOption = { filter: ActivityFilter; @@ -15,43 +9,15 @@ export type ActivityFilterOption = { type UseActivityFiltersResult = { selectedFilter: ActivityFilter; setSelectedFilter: (filter: ActivityFilter) => void; - options: ActivityFilterOption[]; - filteredData: ActionYieldDto[] | undefined; - filteredCount: number; }; -export const useActivityFilters = ( - data: ActionYieldDto[] | undefined -): UseActivityFiltersResult => { +export const useActivityFilters = ({ + options, +}: { + options: ActivityFilterOption[]; +}): UseActivityFiltersResult => { const [selectedFilter, setSelectedFilter] = useState("all"); - const categoryByAction = new Map< - ActionYieldDto, - ActivityFilterCategory | null - >(); - data?.forEach((item) => - categoryByAction.set(item, getActivityFilterCategory(item)) - ); - - const counts = new Map(); - categoryByAction.forEach((category) => { - if (category) counts.set(category, (counts.get(category) ?? 0) + 1); - }); - - const totalCount = data?.length ?? 0; - - const categoryOptions = activityFilterCategories - .filter((category) => (counts.get(category) ?? 0) > 0) - .map((category) => ({ - filter: category, - count: counts.get(category) ?? 0, - })); - - const options: ActivityFilterOption[] = - categoryOptions.length > 0 - ? [{ filter: "all", count: totalCount }, ...categoryOptions] - : []; - const isSelectedAvailable = selectedFilter === "all" || options.some((option) => option.filter === selectedFilter); @@ -60,16 +26,8 @@ export const useActivityFilters = ( ? selectedFilter : "all"; - const filteredData = - !data || effectiveFilter === "all" - ? data - : data.filter((item) => categoryByAction.get(item) === effectiveFilter); - return { selectedFilter: effectiveFilter, setSelectedFilter, - options, - filteredData, - filteredCount: filteredData?.length ?? 0, }; }; diff --git a/packages/widget/src/pages/details/activity-page/hooks/use-activity-page.tsx b/packages/widget/src/pages/details/activity-page/hooks/use-activity-page.tsx index a6ef22c4..949a8f4a 100644 --- a/packages/widget/src/pages/details/activity-page/hooks/use-activity-page.tsx +++ b/packages/widget/src/pages/details/activity-page/hooks/use-activity-page.tsx @@ -9,10 +9,7 @@ import { FallbackContent } from "../../positions-page/components/fallback-conten import type { ActivityFilter } from "../activity-filters"; import { useActivityPageContext } from "../state/activity-page.context"; import type { ActionYieldDto } from "../types"; -import { - type ActivityFilterOption, - useActivityFilters, -} from "./use-activity-filters"; +import type { ActivityFilterOption } from "./use-activity-filters"; type UseActivityPageResult = { content: ReactNode; @@ -22,13 +19,13 @@ type UseActivityPageResult = { allData: ReturnType< typeof useActivityPageContext >["activityActions"]["allItems"]; - filteredData: ReturnType< - typeof useActivityPageContext - >["activityActions"]["allItems"]; filterOptions: ActivityFilterOption[]; selectedFilter: ActivityFilter; onFilterSelect: (filter: ActivityFilter) => void; activityActions: ReturnType["activityActions"]; + showActivityContent: boolean; + showActivityControls: boolean; + showActivityList: boolean; }; export const useActivityPage = (): UseActivityPageResult => { @@ -36,29 +33,29 @@ export const useActivityPage = (): UseActivityPageResult => { const { isConnected, isConnecting } = useSKWallet(); - const { activityActions, onActionSelect } = useActivityPageContext(); - - const allData = activityActions.allItems; - const { + activityActions, + filterOptions, + onActionSelect, selectedFilter, setSelectedFilter, - options: filterOptions, - filteredData, - filteredCount, - } = useActivityFilters(allData); + } = useActivityPageContext(); + + const allData = activityActions.allItems; - const showingCount = filteredCount; + const showingCount = allData?.length ?? 0; const apiTotal = (activityActions.data as { pages: { total?: number }[] } | undefined) ?.pages?.[0]?.total ?? allData?.length ?? 0; - - // When a category filter is active we can only count loaded items, so the - // total reflects the filtered subset instead of the API-reported total. - const total = selectedFilter === "all" ? apiTotal : filteredCount; + const total = apiTotal; + const hasRenderableActivity = !!allData?.length; + const hasActivityFilters = filterOptions.length > 0; + const showActivityControls = !activityActions.isPending && hasActivityFilters; + const showActivityList = !activityActions.isPending && hasRenderableActivity; + const showActivityContent = showActivityControls || showActivityList; const { t } = useTranslation(); @@ -81,7 +78,7 @@ export const useActivityPage = (): UseActivityPageResult => { ); } - if (isConnected && !allData?.length && !activityActions.isPending) { + if (isConnected && !showActivityContent && !activityActions.isPending) { return ( @@ -107,7 +104,7 @@ export const useActivityPage = (): UseActivityPageResult => { }, [ isConnected, isConnecting, - allData, + showActivityContent, activityActions.isPending, activityActions.isFetchingNextPage, t, @@ -119,10 +116,12 @@ export const useActivityPage = (): UseActivityPageResult => { showingCount, total, allData, - filteredData, filterOptions, selectedFilter, onFilterSelect: setSelectedFilter, activityActions, + showActivityContent, + showActivityControls, + showActivityList, }; }; diff --git a/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx b/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx index df122e98..3e2c63ff 100644 --- a/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx +++ b/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx @@ -6,9 +6,14 @@ import { ActionStatus, type TransactionType, } from "../../../../domain/types/action"; -import { useActivityActions } from "../../../../hooks/api/use-activity-actions"; +import { + useActivityActions, + useActivityFilterOptions, + usePrefetchActivityActionFilters, +} from "../../../../hooks/api/use-activity-actions"; import { useActivityContext } from "../../../../providers/activity-provider"; import { useSKWallet } from "../../../../providers/sk-wallet"; +import { useActivityFilters } from "../hooks/use-activity-filters"; import type { ActionYieldDto } from "../types"; import type { ActivityPageContextType } from "./types"; @@ -80,11 +85,19 @@ export const ActivityPageContextProvider = ({ return; }; - const activityActions = useActivityActions(); + const filterOptions = useActivityFilterOptions(); + const { selectedFilter, setSelectedFilter } = useActivityFilters({ + options: filterOptions, + }); + const activityActions = useActivityActions(selectedFilter); + usePrefetchActivityActionFilters({ filterOptions }); const value = { onActionSelect, activityActions, + filterOptions, + selectedFilter, + setSelectedFilter, }; return ( diff --git a/packages/widget/src/pages/details/activity-page/state/types.ts b/packages/widget/src/pages/details/activity-page/state/types.ts index c032771c..ed412b21 100644 --- a/packages/widget/src/pages/details/activity-page/state/types.ts +++ b/packages/widget/src/pages/details/activity-page/state/types.ts @@ -1,7 +1,12 @@ import type { useActivityActions } from "../../../../hooks/api/use-activity-actions"; +import type { ActivityFilter } from "../activity-filters"; +import type { ActivityFilterOption } from "../hooks/use-activity-filters"; import type { ActionYieldDto } from "../types"; export type ActivityPageContextType = { onActionSelect: (val: ActionYieldDto) => void; activityActions: ReturnType; + filterOptions: ActivityFilterOption[]; + selectedFilter: ActivityFilter; + setSelectedFilter: (filter: ActivityFilter) => void; }; diff --git a/packages/widget/src/providers/settings/index.tsx b/packages/widget/src/providers/settings/index.tsx index f7a73fdb..c579414b 100644 --- a/packages/widget/src/providers/settings/index.tsx +++ b/packages/widget/src/providers/settings/index.tsx @@ -70,7 +70,8 @@ export const SettingsContextProvider = ({ value={{ ...rest, preferredTokenYieldsPerNetwork, - yieldGrouping: rest.yieldGrouping ?? "category", + yieldGrouping: + rest.yieldGrouping ?? (rest.dashboardVariant ? "category" : "flat"), }} > {children} diff --git a/packages/widget/src/providers/settings/types.ts b/packages/widget/src/providers/settings/types.ts index 9a51968e..27b631ec 100644 --- a/packages/widget/src/providers/settings/types.ts +++ b/packages/widget/src/providers/settings/types.ts @@ -17,7 +17,7 @@ import type { TrackPageVal, } from "../tracking/types"; -export type YieldGrouping = "flat" | "category"; +type YieldGrouping = "flat" | "category"; export type VariantProps = | { @@ -96,7 +96,7 @@ export type SettingsProps = { initialChain?: SupportedSKChainIds; }; -export type ResolvedSettingsProps = Omit & { +type ResolvedSettingsProps = Omit & { yieldGrouping: YieldGrouping; }; diff --git a/packages/widget/tests/hooks/activity-actions.test.ts b/packages/widget/tests/hooks/activity-actions.test.ts new file mode 100644 index 00000000..af855713 --- /dev/null +++ b/packages/widget/tests/hooks/activity-actions.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ActionsControllerGetActionsParams } from "../../src/generated/api/yield"; +import { + fetchActivityFilterOptions, + getActivityActionsQueryKey, + getActivityActionsRequestParams, +} from "../../src/hooks/api/use-activity-actions"; +import type { ActivityFilter } from "../../src/pages/details/activity-page/activity-filters"; +import type { ApiClient } from "../../src/providers/api/api-client"; + +const address = "0x0000000000000000000000000000000000000001"; +const network = "ethereum"; + +const sort = (values: ReadonlyArray) => [...values].sort(); + +const yieldTypeKey = (values?: ReadonlyArray) => + values ? sort(values).join("|") : ""; + +const filterByYieldTypes = new Map([ + ["", "all"], + [yieldTypeKey(["staking", "restaking"]), "stake"], + [ + yieldTypeKey([ + "lending", + "vault", + "fixed_yield", + "concentrated_liquidity_pool", + "liquidity_pool", + ]), + "defi", + ], + [yieldTypeKey(["real_world_asset"]), "rwa"], +]); + +const getFilterFromParams = ( + params: ActionsControllerGetActionsParams +): ActivityFilter => { + const filter = filterByYieldTypes.get(yieldTypeKey(params.yieldTypes)); + + if (!filter) { + throw new Error(`Unexpected yieldTypes: ${params.yieldTypes?.join(",")}`); + } + + return filter; +}; + +const createApiClient = (totals: Partial>) => { + const calls: ActionsControllerGetActionsParams[] = []; + const ActionsControllerGetActions = vi.fn( + async ({ params }: { params: ActionsControllerGetActionsParams }) => { + calls.push(params); + + return { + items: [], + limit: params.limit ?? 1, + offset: params.offset ?? 0, + total: totals[getFilterFromParams(params)] ?? 0, + }; + } + ); + const apiClient = { + withOptions: vi.fn(() => ({ + yield: { + ActionsControllerGetActions, + }, + })), + } as unknown as ApiClient; + + return { apiClient, calls }; +}; + +describe("activity action request params", () => { + it("omits yieldTypes for all activity", () => { + const params = getActivityActionsRequestParams({ + address, + filter: "all", + limit: 50, + network, + offset: 0, + }); + + expect(params).not.toHaveProperty("yieldTypes"); + expect(params).toMatchObject({ + address, + limit: 50, + network, + offset: 0, + statuses: ["SUCCESS", "FAILED"], + }); + }); + + it.each([ + ["stake", ["staking", "restaking"]], + [ + "defi", + [ + "lending", + "vault", + "fixed_yield", + "concentrated_liquidity_pool", + "liquidity_pool", + ], + ], + ["rwa", ["real_world_asset"]], + ] as const satisfies ReadonlyArray< + readonly [ActivityFilter, ReadonlyArray] + >)("adds %s yieldTypes to activity requests", (filter, yieldTypes) => { + const params = getActivityActionsRequestParams({ + address, + filter, + limit: 50, + network, + offset: 0, + }); + + expect(sort(params.yieldTypes ?? [])).toEqual(sort(yieldTypes)); + }); + + it("uses different query keys for each selected filter", () => { + const allKey = getActivityActionsQueryKey({ + address, + filter: "all", + network, + }); + const stakeKey = getActivityActionsQueryKey({ + address, + filter: "stake", + network, + }); + + expect(allKey).not.toEqual(stakeKey); + expect(stakeKey[1]).toMatchObject({ + filter: "stake", + yieldTypes: ["staking", "restaking"], + }); + }); +}); + +describe("fetchActivityFilterOptions", () => { + it("returns exact all and non-zero category counts without borrow", async () => { + const { apiClient, calls } = createApiClient({ + all: 7, + stake: 2, + defi: 0, + rwa: 3, + }); + + const options = await fetchActivityFilterOptions({ + address, + apiClient, + network, + }); + + expect(options).toEqual([ + { filter: "all", count: 7 }, + { filter: "stake", count: 2 }, + { filter: "rwa", count: 3 }, + ]); + expect(options.map((option) => option.filter)).not.toContain("borrow"); + + expect(calls.map(getFilterFromParams)).toEqual([ + "all", + "stake", + "defi", + "rwa", + ]); + expect( + calls.every( + (params) => + params.address === address && + params.network === network && + params.limit === 1 && + params.offset === 0 && + params.statuses?.join("|") === "SUCCESS|FAILED" + ) + ).toBe(true); + }); +}); diff --git a/packages/widget/tests/use-cases/renders-initial-page.test.tsx b/packages/widget/tests/use-cases/renders-initial-page.test.tsx index 8a597954..1dfe6163 100644 --- a/packages/widget/tests/use-cases/renders-initial-page.test.tsx +++ b/packages/widget/tests/use-cases/renders-initial-page.test.tsx @@ -155,7 +155,7 @@ describe("Renders initial page", () => { app.unmount(); }); - it("uses flat dashboard yield grouping by default", async () => { + it("uses category dashboard yield grouping by default", async () => { const app = await renderApp({ skProps: { apiKey: import.meta.env.VITE_API_KEY, @@ -163,12 +163,19 @@ describe("Renders initial page", () => { }, }); - await expect.element(app.getByText("Earn")).toBeInTheDocument(); + await expect.element(app.getByText("Stake")).toBeInTheDocument(); + await expect.element(app.getByText("DeFi")).toBeInTheDocument(); + await expect.element(app.getByText("RWA")).toBeInTheDocument(); await expect.element(app.getByText("Manage")).toBeInTheDocument(); await expect.element(app.getByText("Activity")).toBeInTheDocument(); - await expect.element(app.getByText("Stake")).not.toBeInTheDocument(); - await expect.element(app.getByText("DeFi")).not.toBeInTheDocument(); - await expect.element(app.getByText("RWA")).not.toBeInTheDocument(); + + const tabsSection = app.container.querySelector("[data-rk='tabs-section']"); + const tabsText = tabsSection?.textContent ?? ""; + + expect(tabsText).toContain("Stake"); + expect(tabsText).toContain("DeFi"); + expect(tabsText).toContain("RWA"); + expect(tabsText).not.toContain("Earn"); app.unmount(); });