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(); + }); });