From e94323e2615300c65aa08b9ecdfbd0bb14ca8952 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 24 Jun 2026 11:02:15 +0200 Subject: [PATCH] feat(widget): add configurable dashboard yield category tab order Allow integrators to reorder RWA, DeFi, and Stake category tabs via dashboardYieldCategoryOrder. Manage and Activity tabs remain fixed. Export DashboardYieldCategory constants and normalize partial or invalid orders to the full category set. --- AGENTS.md | 2 +- README.md | 26 ++++++++++ packages/widget/src/domain/types/yields.ts | 41 ++++++++++++++-- .../hooks/api/use-dashboard-yield-catalog.ts | 9 ++-- packages/widget/src/index.bundle.ts | 1 + packages/widget/src/index.package.ts | 1 + .../positions/hooks/use-grouped-positions.ts | 5 +- .../widget/src/providers/settings/index.tsx | 6 +++ .../widget/src/providers/settings/types.ts | 8 +++- .../dashboard-yield-category-types.test.ts | 47 +++++++++++++++++++ .../use-cases/renders-initial-page.test.tsx | 33 +++++++++++++ 11 files changed, 166 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59d0daac..57d73946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ ## Agent Working Guidelines (short) - Keep public API compatibility in `src/index.package.ts` and `src/index.bundle.ts`. -- React Compiler is enabled. Do not add `useMemo`, `useCallback`, or `React.memo` only for render-performance optimization; prefer plain values/functions. Use manual memoization only when required for semantic stability, such as an external API dependency or context value identity. +- React Compiler is enabled. Do not add `useMemo`, `useCallback`, or `React.memo` only for render-performance optimization; prefer plain values/functions. - When changing user-facing copy, update both: - `packages/widget/src/translation/English/translations.json` - `packages/widget/src/translation/French/translations.json` diff --git a/README.md b/README.md index 17dcc4bb..18daf00a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ type SettingsProps = { | Record | ((chain: SupportedSKChains) => string); dashboardVariant?: boolean; + dashboardYieldCategoryOrder?: DashboardYieldCategory[]; hideChainSelector?: boolean; hideAccountAndChainSelector?: boolean; preferredTokenYieldsPerNetwork?: PreferredTokenYieldsPerNetwork; @@ -123,6 +124,31 @@ type SettingsProps = { }; ``` +### Dashboard category tab order + +For the dashboard variant, you can reorder the `RWA`, `DeFi`, and `Stake` +category tabs. `Manage` and `Activity` stay fixed after those category tabs. + +```tsx +import "@stakekit/widget/package/css"; +import { DashboardYieldCategory, SKApp, darkTheme } from "@stakekit/widget"; + +const App = () => { + return ( + + ); +}; +``` + ### Override Icons You can override token or chain icons in widget diff --git a/packages/widget/src/domain/types/yields.ts b/packages/widget/src/domain/types/yields.ts index ac286ce5..4b5d26aa 100644 --- a/packages/widget/src/domain/types/yields.ts +++ b/packages/widget/src/domain/types/yields.ts @@ -68,14 +68,47 @@ export type ValidatorsConfig = Map< } >; -export type DashboardYieldCategory = "stake" | "defi" | "rwa"; +export const DashboardYieldCategory = { + RWA: "rwa", + DeFi: "defi", + Stake: "stake", +} as const; + +export type DashboardYieldCategory = + (typeof DashboardYieldCategory)[keyof typeof DashboardYieldCategory]; export const dashboardYieldCategories = [ - "rwa", - "defi", - "stake", + DashboardYieldCategory.RWA, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.Stake, ] as const satisfies ReadonlyArray; +const dashboardYieldCategoryValues = new Set(dashboardYieldCategories); + +export const normalizeDashboardYieldCategoryOrder = ( + order?: ReadonlyArray | null +): DashboardYieldCategory[] => { + const normalized: DashboardYieldCategory[] = []; + + for (const category of order ?? []) { + if ( + typeof category === "string" && + dashboardYieldCategoryValues.has(category) && + !normalized.includes(category as DashboardYieldCategory) + ) { + normalized.push(category as DashboardYieldCategory); + } + } + + for (const category of dashboardYieldCategories) { + if (!normalized.includes(category)) { + normalized.push(category); + } + } + + return normalized; +}; + /** * Maps every API `YieldType` to exactly one dashboard category. The * `satisfies Record` guarantees exhaustiveness: a new server diff --git a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts index 876e0a41..6ee2dfa8 100644 --- a/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts +++ b/packages/widget/src/hooks/api/use-dashboard-yield-catalog.ts @@ -3,10 +3,10 @@ import { useMemo } from "react"; import type { TokenDto } from "../../domain/types/tokens"; import { type DashboardYieldCategory, - dashboardYieldCategories, getApiYieldTypesForDashboardCategory, } from "../../domain/types/yields"; import { useApiClient } from "../../providers/api/api-client-provider"; +import { useSettings } from "../../providers/settings"; import { useSKWallet } from "../../providers/sk-wallet"; import { DEFAULT_YIELD_SUMMARIES_PAGE_LIMIT, @@ -38,11 +38,12 @@ export const useDashboardYieldCatalog = ({ } = {}) => { const { network, isConnecting } = useSKWallet(); const apiClient = useApiClient(); + const { dashboardYieldCategoryOrder } = useSettings(); const probeEnabled = enabled && !isConnecting; const results = useQueries({ - queries: dashboardYieldCategories.map((category) => { + queries: dashboardYieldCategoryOrder.map((category) => { const params: YieldSummariesParams = { ...(network ? { network: network } : {}), types: getApiYieldTypesForDashboardCategory(category), @@ -67,7 +68,7 @@ export const useDashboardYieldCatalog = ({ >(); const availableCategories: DashboardYieldCategory[] = []; - dashboardYieldCategories.forEach((category, index) => { + dashboardYieldCategoryOrder.forEach((category, index) => { const firstVisible = (results[index]?.data ?? []).find( isVisibleYieldSummary ); @@ -87,5 +88,5 @@ export const useDashboardYieldCatalog = ({ initialSelectionByCategory, isLoading: probeEnabled && results.some((result) => result.isLoading), }; - }, [results, probeEnabled]); + }, [dashboardYieldCategoryOrder, results, probeEnabled]); }; diff --git a/packages/widget/src/index.bundle.ts b/packages/widget/src/index.bundle.ts index 0b4d4432..107ecace 100644 --- a/packages/widget/src/index.bundle.ts +++ b/packages/widget/src/index.bundle.ts @@ -5,4 +5,5 @@ export { EvmChainIds } from "./domain/types/chains/evm"; export { MiscChainIds } from "./domain/types/chains/misc"; export { SubstrateChainIds } from "./domain/types/chains/substrate"; export type * from "./domain/types/wallets/generic-wallet"; +export { DashboardYieldCategory } from "./domain/types/yields"; export { darkTheme, lightTheme } from "./styles/theme/themes"; diff --git a/packages/widget/src/index.package.ts b/packages/widget/src/index.package.ts index d97f8649..c9600aba 100644 --- a/packages/widget/src/index.package.ts +++ b/packages/widget/src/index.package.ts @@ -7,6 +7,7 @@ export { EvmChainIds } from "./domain/types/chains/evm"; export { MiscChainIds } from "./domain/types/chains/misc"; export { SubstrateChainIds } from "./domain/types/chains/substrate"; export type * from "./domain/types/wallets/generic-wallet"; +export { DashboardYieldCategory } from "./domain/types/yields"; export { TrackingContextProvider } from "./providers/tracking"; export { createWallet } from "./providers/wagmi/utils"; export { darkTheme, lightTheme } from "./styles/theme/themes"; diff --git a/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts b/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts index fead5589..bf4fce8f 100644 --- a/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts +++ b/packages/widget/src/pages-dashboard/overview/positions/hooks/use-grouped-positions.ts @@ -1,7 +1,6 @@ import { useQueries } from "@tanstack/react-query"; import { type DashboardYieldCategory, - dashboardYieldCategories, getDashboardYieldCategory, } from "../../../../domain/types/yields"; import { queryFn } from "../../../../hooks/api/use-yield-opportunity/get-yield-opportunity"; @@ -33,7 +32,7 @@ export const useGroupedPositions = ( const { isLedgerLive } = useSKWallet(); const apiClient = useApiClient(); const queryClient = useSKQueryClient(); - const { yieldGrouping } = useSettings(); + const { dashboardYieldCategoryOrder, yieldGrouping } = useSettings(); const dashboardYieldCategoryGroupingEnabled = yieldGrouping === "category"; const integrationIds = dashboardYieldCategoryGroupingEnabled @@ -83,7 +82,7 @@ export const useGroupedPositions = ( const rows: PositionsListRow[] = [{ kind: "chain-modal" }]; - for (const category of dashboardYieldCategories) { + for (const category of dashboardYieldCategoryOrder) { const items = grouped.get(category); if (!items?.length) continue; diff --git a/packages/widget/src/providers/settings/index.tsx b/packages/widget/src/providers/settings/index.tsx index c579414b..55191a8c 100644 --- a/packages/widget/src/providers/settings/index.tsx +++ b/packages/widget/src/providers/settings/index.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from "react"; import { createContext, useContext, useLayoutEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { config } from "../../config"; +import { normalizeDashboardYieldCategoryOrder } from "../../domain/types/yields"; import utilaTranslations from "../../translation/English/utila-variant.json"; import type { SettingsContextType, SettingsProps, VariantProps } from "./types"; @@ -39,6 +40,10 @@ export const SettingsContextProvider = ({ .extract() as typeof rest.preferredTokenYieldsPerNetwork; }, [rest.preferredTokenYieldsPerNetwork]); + const dashboardYieldCategoryOrder = normalizeDashboardYieldCategoryOrder( + rest.dashboardYieldCategoryOrder + ); + const { i18n } = useTranslation(); useLayoutEffect(() => { @@ -69,6 +74,7 @@ export const SettingsContextProvider = ({ | ((chain: SupportedSKChains) => string); dashboardVariant?: boolean; + dashboardYieldCategoryOrder?: DashboardYieldCategory[]; yieldGrouping?: YieldGrouping; institutionalWallets?: boolean; hideChainSelector?: boolean; @@ -96,7 +98,11 @@ export type SettingsProps = { initialChain?: SupportedSKChainIds; }; -type ResolvedSettingsProps = Omit & { +type ResolvedSettingsProps = Omit< + SettingsProps, + "dashboardYieldCategoryOrder" | "yieldGrouping" +> & { + dashboardYieldCategoryOrder: DashboardYieldCategory[]; yieldGrouping: YieldGrouping; }; diff --git a/packages/widget/tests/domain/dashboard-yield-category-types.test.ts b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts index b6547e45..4a8dc971 100644 --- a/packages/widget/tests/domain/dashboard-yield-category-types.test.ts +++ b/packages/widget/tests/domain/dashboard-yield-category-types.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + DashboardYieldCategory, dashboardYieldCategories, getApiYieldTypesForDashboardCategory, getYieldTypesSortRank, + normalizeDashboardYieldCategoryOrder, type YieldBase, } from "../../src/domain/types/yields"; @@ -53,6 +55,51 @@ describe("getApiYieldTypesForDashboardCategory", () => { }); }); +describe("normalizeDashboardYieldCategoryOrder", () => { + it("uses the default category order when no order is provided", () => { + expect(normalizeDashboardYieldCategoryOrder()).toEqual([ + "rwa", + "defi", + "stake", + ]); + }); + + it("preserves a complete custom category order", () => { + expect( + normalizeDashboardYieldCategoryOrder([ + DashboardYieldCategory.Stake, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.RWA, + ]) + ).toEqual([ + DashboardYieldCategory.Stake, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.RWA, + ]); + }); + + it("deduplicates categories by keeping their first occurrence", () => { + expect( + normalizeDashboardYieldCategoryOrder([ + DashboardYieldCategory.Stake, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.Stake, + DashboardYieldCategory.RWA, + ]) + ).toEqual([ + DashboardYieldCategory.Stake, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.RWA, + ]); + }); + + it("ignores runtime-invalid entries and appends missing valid categories", () => { + expect( + normalizeDashboardYieldCategoryOrder(["stake", "invalid", "defi"]) + ).toEqual(["stake", "defi", "rwa"]); + }); +}); + describe("getYieldTypesSortRank", () => { const makeYield = (type: YieldBase["mechanics"]["type"]): YieldBase => ({ 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 1dfe6163..e5d9af5a 100644 --- a/packages/widget/tests/use-cases/renders-initial-page.test.tsx +++ b/packages/widget/tests/use-cases/renders-initial-page.test.tsx @@ -1,4 +1,5 @@ import { delay, HttpResponse, http } from "msw"; +import { DashboardYieldCategory } from "../../src/domain/types/yields"; import { legacyYieldFixture, yieldApiYieldFixture } from "../fixtures"; import { legacyApiRoute, yieldApiRoute } from "../mocks/api-routes"; import { describe, expect, it } from "../utils/test-extend"; @@ -179,4 +180,36 @@ describe("Renders initial page", () => { app.unmount(); }); + + it("uses the configured dashboard category tab order", async () => { + const app = await renderApp({ + skProps: { + apiKey: import.meta.env.VITE_API_KEY, + dashboardVariant: true, + dashboardYieldCategoryOrder: [ + DashboardYieldCategory.Stake, + DashboardYieldCategory.DeFi, + DashboardYieldCategory.RWA, + ], + }, + }); + + 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(); + + const tabsSection = app.container.querySelector("[data-rk='tabs-section']"); + const tabsText = tabsSection?.textContent ?? ""; + + expect(tabsText.indexOf("Stake")).toBeLessThan(tabsText.indexOf("DeFi")); + expect(tabsText.indexOf("DeFi")).toBeLessThan(tabsText.indexOf("RWA")); + expect(tabsText.indexOf("RWA")).toBeLessThan(tabsText.indexOf("Manage")); + expect(tabsText.indexOf("Manage")).toBeLessThan( + tabsText.indexOf("Activity") + ); + + app.unmount(); + }); });